package tunnel import ( "net/url" "strings" "testing" "time" ) // TestBackoffValues 驗證退避函式輸出。 // // 這組測試的核心動機是 POC 的 backoff bug:math.Min 單位 mix 造成 attempt>=1 // 永遠回 30s。修復後應該是純指數退避,clamp 在 [base, cap]。 func TestBackoffValues(t *testing.T) { cases := []struct { attempt int want time.Duration }{ {attempt: 0, want: backoffBase}, // 不應發生,但定義行為 {attempt: 1, want: 1 * time.Second}, // base × 2^0 = 1s {attempt: 2, want: 2 * time.Second}, // base × 2^1 = 2s {attempt: 3, want: 4 * time.Second}, {attempt: 4, want: 8 * time.Second}, {attempt: 5, want: 16 * time.Second}, {attempt: 6, want: backoffCap}, // 32s > 30s → cap {attempt: 7, want: backoffCap}, {attempt: 10, want: backoffCap}, {attempt: 30, want: backoffCap}, {attempt: 100, want: backoffCap}, // 極大 attempt 應不 overflow } for _, tc := range cases { got := backoff(tc.attempt) if got != tc.want { t.Errorf("backoff(%d) = %v, want %v", tc.attempt, got, tc.want) } } } // TestBackoffMonotonicUntilCap 驗證退避在 cap 之前是遞增的。 // 這是 POC bug 會失敗的測試(POC 從 attempt=1 起就固定 30s,根本不遞增)。 func TestBackoffMonotonicUntilCap(t *testing.T) { var prev time.Duration for attempt := 1; attempt <= 5; attempt++ { got := backoff(attempt) if got <= prev { t.Errorf("backoff not monotonically increasing: backoff(%d)=%v, backoff(%d)=%v", attempt-1, prev, attempt, got) } if got > backoffCap { t.Errorf("backoff(%d)=%v exceeds cap %v", attempt, got, backoffCap) } prev = got } } // TestKeepAliveIntervalDefault 固定住 10s 預設值,避免未來不小心改回 POC 的 30s。 // 10s 是 tunnel.md §4.2 M-5 統一的心跳週期。 func TestKeepAliveIntervalDefault(t *testing.T) { if KeepAliveInterval != 10*time.Second { t.Errorf("KeepAliveInterval = %v, want 10s (tunnel.md §4.2 M-5)", KeepAliveInterval) } } // TestClientInsecureSkipTLSVerifyDefaultFalse 驗證 NewClient 預設不跳過 TLS 驗證 // (production-safe default)。 func TestClientInsecureSkipTLSVerifyDefaultFalse(t *testing.T) { c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil) if c.insecureSkipTLSVerify { t.Error("NewClient should default insecureSkipTLSVerify=false") } } // TestClientSetInsecureSkipTLSVerify 驗證 setter 正確翻轉欄位。 func TestClientSetInsecureSkipTLSVerify(t *testing.T) { c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil) c.SetInsecureSkipTLSVerify(true) if !c.insecureSkipTLSVerify { t.Error("SetInsecureSkipTLSVerify(true) should set field true") } c.SetInsecureSkipTLSVerify(false) if c.insecureSkipTLSVerify { t.Error("SetInsecureSkipTLSVerify(false) should set field false") } } // TestManagerPropagatesInsecureSkipTLSVerify 驗證 Manager.Config.InsecureSkipTLSVerify // 會經 newClientLocked 傳到 Client。 func TestManagerPropagatesInsecureSkipTLSVerify(t *testing.T) { cases := []struct { name string skip bool }{ {"enabled", true}, {"disabled", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { m := NewManager(Config{ RelayURL: "wss://relay/tunnel/connect", SessionToken: "vAs_" + repeat64, LocalAddr: "127.0.0.1:1234", InsecureSkipTLSVerify: tc.skip, }) m.mu.Lock() c := m.newClientLocked() m.mu.Unlock() if c.insecureSkipTLSVerify != tc.skip { t.Errorf("client.insecureSkipTLSVerify = %v, want %v", c.insecureSkipTLSVerify, tc.skip) } }) } } // repeat64 是 64 個 'a',組成合法長度的假 session token(vAs_ + 64 hex)。 const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // TestTLSConfigForDialForcesHTTP11ALPN 驗證撥 WSS 的 TLS config 強制 ALPN http/1.1, // 且 insecure 開 / 關 兩種分支都有設 NextProtos。 // // 動機:ALPN 留空 → 公網反代選 HTTP/2 → h2 剝除 Connection:Upgrade → bad handshake。 // 釘死 http/1.1 才能讓 WebSocket 升級 header 不被剝。 func TestTLSConfigForDialForcesHTTP11ALPN(t *testing.T) { cases := []struct { name string skipVerify bool }{ {"正式分支(不 skip TLS 驗證)", false}, {"insecure 分支(skip TLS 驗證)", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := TLSConfigForDial(tc.skipVerify) if cfg == nil { t.Fatal("TLSConfigForDial returned nil") } // NextProtos 必須正好是 ["http/1.1"](強制 h1.1,不留空讓反代選 h2) if len(cfg.NextProtos) != 1 || cfg.NextProtos[0] != "http/1.1" { t.Errorf("NextProtos = %v, want [\"http/1.1\"]", cfg.NextProtos) } // InsecureSkipVerify 應反映入參(ALPN 修正不得改動 skip 邏輯本身) if cfg.InsecureSkipVerify != tc.skipVerify { t.Errorf("InsecureSkipVerify = %v, want %v", cfg.InsecureSkipVerify, tc.skipVerify) } }) } } // TestNormalizeRelayURL 驗證 relay URL 兜底補 /tunnel/connect path 的邏輯。 // // 動機:exchange 回的 relay_url / agentconfig 預設值是裸 host(無 path),直接 Dial // path=/ 會被 nginx 導去 frontend → bad handshake。normalizeRelayURL 補 path; // 但對已含非根 path 的 URL 必須冪等(不重複加)。 func TestNormalizeRelayURL(t *testing.T) { cases := []struct { name string in string want string }{ { name: "base 無 path(stage 實際情境)", in: "wss://stage-9527.innovedus.com:9527", want: "wss://stage-9527.innovedus.com:9527/tunnel/connect", }, { name: "base path = /(trailing slash)", in: "wss://stage-9527.innovedus.com:9527/", want: "wss://stage-9527.innovedus.com:9527/tunnel/connect", }, { name: "已含 /tunnel/connect(冪等、不重複加)", in: "wss://relay.visionA.cloud/tunnel/connect", want: "wss://relay.visionA.cloud/tunnel/connect", }, { name: "已含其他非根 path(尊重既有)", in: "wss://relay.example.com/custom/path", want: "wss://relay.example.com/custom/path", }, { name: "ws(非 TLS)裸 host 也補", in: "ws://127.0.0.1:8080", want: "ws://127.0.0.1:8080/tunnel/connect", }, { name: "agentconfig 預設值(裸 host)", in: "wss://relay.visionA.cloud", want: "wss://relay.visionA.cloud/tunnel/connect", }, { name: "base 無 path 但帶既有 query(query 保留、補 path)", in: "wss://host:9527?foo=bar", want: "wss://host:9527/tunnel/connect?foo=bar", }, { name: "base path=/ 帶既有 query", in: "wss://host:9527/?foo=bar", want: "wss://host:9527/tunnel/connect?foo=bar", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := normalizeRelayURL(tc.in) if got != tc.want { t.Errorf("normalizeRelayURL(%q) = %q, want %q", tc.in, got, tc.want) } }) } } // TestNormalizeRelayURLThenTokenAppended 驗證 normalize 後,connect() 附加 ?token= // 的邏輯仍能正確把 token 加在最終 URL(path 已補好、token query 正確)。 // // 複製 connect() 的 URL 組裝步驟(normalize → parse → set token query)以驗證最終形狀, // 不實際 Dial。 func TestNormalizeRelayURLThenTokenAppended(t *testing.T) { const token = "vAs_" + repeat64 cases := []struct { name string relay string wantPath string wantToken string wantHasPath bool }{ { name: "裸 host → 補 path 且 token 正確", relay: "wss://stage-9527.innovedus.com:9527", wantPath: "/tunnel/connect", wantToken: token, }, { name: "已含 path → 不重複加且 token 正確", relay: "wss://relay.visionA.cloud/tunnel/connect", wantPath: "/tunnel/connect", wantToken: token, }, { name: "裸 host 帶既有 query → path 補上、既有 query + token 並存", relay: "wss://host:9527?foo=bar", wantPath: "/tunnel/connect", wantToken: token, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // 複製 connect() 的組裝邏輯 u, err := url.Parse(normalizeRelayURL(tc.relay)) if err != nil { t.Fatalf("parse: %v", err) } q := u.Query() if token != "" { q.Set("token", token) } u.RawQuery = q.Encode() if u.Path != tc.wantPath { t.Errorf("path = %q, want %q", u.Path, tc.wantPath) } if got := u.Query().Get("token"); got != tc.wantToken { t.Errorf("token query = %q, want %q", got, tc.wantToken) } // 最終 URL 字串應同時含 path 與 token= final := u.String() if !strings.Contains(final, tc.wantPath) { t.Errorf("final URL %q missing path %q", final, tc.wantPath) } if !strings.Contains(final, "token=") { t.Errorf("final URL %q missing token= query", final) } // 防回歸:絕不可出現重複 path if strings.Contains(final, "/tunnel/connect/tunnel/connect") { t.Errorf("final URL %q has duplicated /tunnel/connect path", final) } }) } }