单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
325 lines
9.9 KiB
Go
325 lines
9.9 KiB
Go
// manager_test.go:出口决策(deny/sticky/unhealthy/主备区域组)+ API golden +
|
||
// 日志脱敏断言(无外网;mihomo controller 用 httptest 环回 stub)。
|
||
package proxymanager
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
_ "modernc.org/sqlite" // sqlite driver(TestAPIRulesReload 需要)
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/store"
|
||
)
|
||
|
||
// newTestManager 构造无 store、无 controller 依赖的 Manager(探针 stub 注入,
|
||
// healthy=true 模拟订阅已就绪)。
|
||
func TestNewManagerWiresProber(t *testing.T) {
|
||
m := NewManager(nil, t.TempDir(), nil, "http://127.0.0.1:1", "", nil)
|
||
if m.health.prober == nil {
|
||
t.Fatal("生产 NewManager 必须接线 mihomo delay 探针(W6 panic)")
|
||
}
|
||
}
|
||
|
||
func newTestManager(t *testing.T, prober Prober) *Manager {
|
||
t.Helper()
|
||
m := NewManager(nil, t.TempDir(), []string{"http://127.0.0.1:1/unused"}, "http://127.0.0.1:1", "", nil)
|
||
m.health.prober = prober
|
||
m.health.ReplacePool(metaData())
|
||
p := prober.(*stubProber)
|
||
p.set("US-02", 160, nil)
|
||
p.set("US-03", 170, nil)
|
||
p.set("JP-06", 280, nil)
|
||
m.mu.Lock()
|
||
m.healthy = true // 测试前置:跳过 Refresh(Refresh 依赖真实订阅源)
|
||
m.mu.Unlock()
|
||
return m
|
||
}
|
||
|
||
// stubController 恒 200 的 controller 环回(delay API 假响应)。
|
||
func stubController(delayMS int) *httptest.Server {
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if strings.Contains(r.URL.Path, "/delay") {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
fmt.Fprintf(w, `{"delay": %d}`, delayMS)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
fmt.Fprint(w, `{}`)
|
||
}))
|
||
}
|
||
|
||
func TestControllerProbeNode(t *testing.T) {
|
||
srv := stubController(123)
|
||
defer srv.Close()
|
||
c := newController(srv.URL, "", timeSecond)
|
||
d, err := c.ProbeNode(context.Background(), "US-02")
|
||
if err != nil || d != 123 {
|
||
t.Fatalf("delay = %d, err = %v", d, err)
|
||
}
|
||
// 节点名 URL 转义(含空格/emoji 名)
|
||
if _, err := c.ProbeNode(context.Background(), "🇺🇸 美国 01"); err != nil {
|
||
t.Errorf("带空格/emoji 名探活失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestControllerBearerAuth(t *testing.T) {
|
||
var gotAuth string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotAuth = r.Header.Get("Authorization")
|
||
if strings.Contains(r.URL.Path, "/delay") {
|
||
fmt.Fprintf(w, `{"delay": 100}`)
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
c := newController(srv.URL, "test-secret-1", 0)
|
||
_, _ = c.ProbeNode(context.Background(), "X")
|
||
if gotAuth != "Bearer test-secret-1" {
|
||
t.Errorf("bearer 头 = %q", gotAuth)
|
||
}
|
||
// 无 secret 不带头
|
||
c2 := newController(srv.URL, "", 0)
|
||
_, _ = c2.ProbeNode(context.Background(), "X")
|
||
if gotAuth != "" {
|
||
t.Errorf("无 secret 不应带认证头, got %q", gotAuth)
|
||
}
|
||
}
|
||
|
||
func TestManagerDenyDomain(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
// 内存注入 deny 规则(同包调用 trie 内部 insert,语义与 LoadFromStore 一致)
|
||
addTestDeny(m, "blocked.com")
|
||
d := m.GetExit("www.blocked.com", "")
|
||
if !d.Blocked || d.Reason != "deny_rule" {
|
||
t.Errorf("deny 域决策 = %+v", d)
|
||
}
|
||
// 正常域不受影响
|
||
d2 := m.GetExit("www.example.com", "")
|
||
if d2.Blocked {
|
||
t.Errorf("正常域误拒: %+v", d2)
|
||
}
|
||
}
|
||
|
||
func TestManagerStickySameNode(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
d1 := m.GetExit("example.com", "")
|
||
d2 := m.GetExit("example.com", "")
|
||
if d1.Node != d2.Node || !d2.Sticky {
|
||
t.Errorf("同域应 sticky 钉死: %+v vs %+v", d1, d2)
|
||
}
|
||
if d1.Proxy != mixedProxyURL {
|
||
t.Errorf("统一 mixed 出口 = %q", d1.Proxy)
|
||
}
|
||
}
|
||
|
||
func TestManagerSessionSticky(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
d1 := m.GetExit("", "sess-1")
|
||
d2 := m.GetExit("other.com", "sess-1")
|
||
if d1.Node != d2.Node {
|
||
t.Error("同 session 跨域应钉死同一节点")
|
||
}
|
||
}
|
||
|
||
func TestManagerUnhealthyFailClosed(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
// 订阅失败且无缓存 → Refresh fail-closed → 全部 exit unhealthy
|
||
m.subs = newSubscriptionCache([]string{"http://127.0.0.1:1/never"})
|
||
if err := m.Refresh(context.Background()); err == nil {
|
||
t.Fatal("Refresh 应失败")
|
||
}
|
||
d := m.GetExit("example.com", "")
|
||
if !d.Blocked || !strings.Contains(d.Reason, "unhealthy") {
|
||
t.Errorf("fail-closed 决策 = %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestManagerRegionFallback(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
// 全部美国节点摘除 → 回退日本组
|
||
p := m.health.prober.(*stubProber)
|
||
p.set("US-02", 0, errors.New("down"))
|
||
p.set("US-03", 0, errors.New("down"))
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-02"])
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-02"])
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-03"])
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-03"])
|
||
if m.health.RegionOf("US-02") == "" {
|
||
t.Fatal("前置失败")
|
||
}
|
||
d := m.GetExit("example.com", "")
|
||
if d.Node == "" || strings.Contains(d.Node, "US-") {
|
||
t.Errorf("美国组全摘后应回退日本组, got %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestAPIHealthzGolden(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
srv := httptest.NewServer(http.HandlerFunc(m.handleHealthz))
|
||
defer srv.Close()
|
||
resp, err := http.Get(srv.URL)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
var body map[string]any
|
||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, k := range []string{"ok", "pool_alive", "active_exit", "last_switch"} {
|
||
if _, ok := body[k]; !ok {
|
||
t.Errorf("healthz 缺字段 %s: %v", k, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAPIProxiesGolden(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
srv := httptest.NewServer(http.HandlerFunc(m.handleProxies))
|
||
defer srv.Close()
|
||
resp, err := http.Get(srv.URL)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := ioReadAll(resp.Body)
|
||
var body struct {
|
||
Proxies []struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Region string `json:"region"`
|
||
Alive bool `json:"alive"`
|
||
DelayMs int `json:"delay_ms"`
|
||
} `json:"proxies"`
|
||
RegionEWMA map[string]float64 `json:"region_ewma"`
|
||
}
|
||
if err := json.Unmarshal(raw, &body); err != nil {
|
||
t.Fatalf("解析失败: %v (%s)", err, raw)
|
||
}
|
||
if len(body.Proxies) != 3 {
|
||
t.Fatalf("proxies = %d, want 3", len(body.Proxies))
|
||
}
|
||
// 脱敏:/api/proxies 响应不含 server/凭据字段(json 字段面即契约)
|
||
if strings.Contains(string(raw), "example.com") {
|
||
t.Fatal("响应泄漏 server 主机名")
|
||
}
|
||
if strings.Contains(string(raw), "uuid") || strings.Contains(string(raw), "password") {
|
||
t.Fatal("响应含凭据字段")
|
||
}
|
||
}
|
||
|
||
func TestAPIExitDeny(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
// 注入 deny 规则(内存)
|
||
addTestDeny(m, "blocked.com")
|
||
srv := httptest.NewServer(http.HandlerFunc(m.handleExit))
|
||
defer srv.Close()
|
||
resp, err := http.Get(srv.URL + "?domain=www.blocked.com")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := ioReadAll(resp.Body)
|
||
var d contractExit
|
||
if err := json.Unmarshal(raw, &d); err != nil {
|
||
t.Fatalf("解析失败: %v (%s)", err, raw)
|
||
}
|
||
if !d.Blocked || d.Reason != "deny_rule" {
|
||
t.Errorf("deny 决策 = %+v", d)
|
||
}
|
||
}
|
||
|
||
// contractExit 与 contract.ProxyExitDecision 字段面一致(golden 双保险)。
|
||
type contractExit struct {
|
||
Proxy string `json:"proxy"`
|
||
Node string `json:"node"`
|
||
Region string `json:"region"`
|
||
Sticky bool `json:"sticky"`
|
||
Blocked bool `json:"blocked"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
func TestAPIExitValidation(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
srv := httptest.NewServer(http.HandlerFunc(m.handleExit))
|
||
defer srv.Close()
|
||
// 缺参 400
|
||
resp, _ := http.Get(srv.URL)
|
||
if resp.StatusCode != 400 {
|
||
t.Errorf("缺参应 400, got %d", resp.StatusCode)
|
||
}
|
||
resp.Body.Close()
|
||
// domain 超长 400
|
||
resp2, _ := http.Get(srv.URL + "?domain=" + strings.Repeat("a", 300))
|
||
if resp2.StatusCode != 400 {
|
||
t.Errorf("超长 domain 应 400, got %d", resp2.StatusCode)
|
||
}
|
||
resp2.Body.Close()
|
||
// 非法字符 400
|
||
resp3, _ := http.Get(srv.URL + "?domain=bad_domain!")
|
||
if resp3.StatusCode != 400 {
|
||
t.Errorf("非法 domain 应 400, got %d", resp3.StatusCode)
|
||
}
|
||
resp3.Body.Close()
|
||
}
|
||
|
||
func TestAPIRulesReload(t *testing.T) {
|
||
m := newTestManager(t, newStubProber())
|
||
// 带 SQLite:rules 表注入一条 deny → reload 后生效
|
||
db, err := store.Open(t.TempDir() + "/test.db")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.Close()
|
||
if err := db.Migrate(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.RuleUpsert("suffix", ".denied.io", "deny", 10); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m.db = db
|
||
srv := httptest.NewServer(http.HandlerFunc(m.handleRulesReload))
|
||
defer srv.Close()
|
||
resp, _ := http.Post(srv.URL, "", nil)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("reload 应 200, got %d", resp.StatusCode)
|
||
}
|
||
resp.Body.Close()
|
||
d := m.GetExit("api.denied.io", "")
|
||
if !d.Blocked || d.Reason != "deny_rule" {
|
||
t.Errorf("reload 后 deny 未生效: %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestLogsRedactCredentials(t *testing.T) {
|
||
// 日志脱敏:manager 日志只含节点名/区域/计数,无 server/uuid/password/URL
|
||
var buf logBuffer
|
||
p := newStubProber()
|
||
p.set("US-02", 160, nil)
|
||
p.set("US-03", 170, nil)
|
||
p.set("JP-06", 280, nil)
|
||
m := NewManager(nil, t.TempDir(), nil, "http://127.0.0.1:1", "", newBufLogger(&buf))
|
||
m.health.prober = p
|
||
m.health.ReplacePool(metaData())
|
||
// 触发各类日志路径
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-02"])
|
||
m.health.probeOne(context.Background(), m.health.nodes["US-03"])
|
||
for i := 0; i < failThreshold; i++ {
|
||
p.set("JP-06", 0, errors.New("down"))
|
||
m.health.probeOne(context.Background(), m.health.nodes["JP-06"])
|
||
}
|
||
p.set("JP-06", 280, nil)
|
||
m.health.probeOne(context.Background(), m.health.nodes["JP-06"])
|
||
m.GetExit("example.com", "sess-x")
|
||
out := buf.String()
|
||
for _, secret := range []string{"a.example.com", "b.example.com", "c.example.com", "uuid", "password", "http://"} {
|
||
if strings.Contains(out, secret) {
|
||
t.Errorf("日志泄漏 %q: %s", secret, out)
|
||
}
|
||
}
|
||
}
|