onesvm-browser-server/server/internal/proxymanager/manager_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

325 lines
9.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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