// 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) } } }