// pairing_test.go — ValidatePairingToken / HTTPPairingExchanger 測試(AB5 範圍)。 package tunnel import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" ) func TestValidatePairingToken(t *testing.T) { cases := []struct { in string wantErr bool }{ {"vAc_0123456789abcdef0123456789abcdef", false}, {"vAc_ffffffffffffffffffffffffffffffff", false}, {"vAs_0123456789abcdef0123456789abcdef", true}, // wrong prefix {"vAc_0123456789abcdef0123456789abcde", true}, // 31 hex {"vAc_0123456789abcdef0123456789abcdef0", true}, // 33 hex {"vAc_ABCDEF0123456789ABCDEF0123456789", true}, // uppercase {"vAc_ghijklmnopqrstuvwxyz0123456789ab", true}, // non-hex {"", true}, {"bogus", true}, } for _, tc := range cases { err := ValidatePairingToken(tc.in) if (err != nil) != tc.wantErr { t.Errorf("ValidatePairingToken(%q) err = %v, wantErr = %v", tc.in, err, tc.wantErr) } } } func TestExchangeMockMode(t *testing.T) { ex := &HTTPPairingExchanger{ CloudAPIURL: "http://unused", MockMode: true, } result, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err != nil { t.Fatalf("Exchange: %v", err) } if !strings.HasPrefix(result.SessionToken, "vAs_") { t.Errorf("SessionToken %q should start with vAs_", result.SessionToken) } // vAs_ + 64 hex = 68 chars if len(result.SessionToken) != 68 { t.Errorf("SessionToken length = %d, want 68", len(result.SessionToken)) } if result.Account != "demo@visionA.local" { t.Errorf("Account = %q, want demo@visionA.local", result.Account) } } func TestExchangeMockModeCustomAccount(t *testing.T) { ex := &HTTPPairingExchanger{ MockMode: true, MockAccount: "override@x", MockRelayURL: "wss://mock-relay/tunnel/connect", } r, err := ex.Exchange("vAc_00000000000000000000000000000000") if err != nil { t.Fatalf("Exchange: %v", err) } if r.Account != "override@x" { t.Errorf("Account = %q, want override@x", r.Account) } if r.RelayURL != "wss://mock-relay/tunnel/connect" { t.Errorf("RelayURL = %q", r.RelayURL) } } func TestExchangeInvalidFormat(t *testing.T) { ex := &HTTPPairingExchanger{MockMode: true} _, err := ex.Exchange("not-a-token") if !errors.Is(err, ErrInvalidTokenFormat) { t.Errorf("err = %v, want ErrInvalidTokenFormat", err) } } func TestExchangeRealSuccess(t *testing.T) { // fake visionA-backend 接 /api/pairing/exchange 回成功 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/pairing/exchange" { w.WriteHeader(404) return } if r.Method != http.MethodPost { w.WriteHeader(405) return } var req exchangeRequest _ = json.NewDecoder(r.Body).Decode(&req) if req.PairingToken == "" { w.WriteHeader(400) return } w.Header().Set("Content-Type", "application/json") // 雲端真實格式:{success:true, data:{...}}(api/errors.go WriteSuccess envelope)。 _ = json.NewEncoder(w).Encode(exchangeResponse{ Success: true, Data: exchangeResponseData{ SessionToken: "vAs_" + strings.Repeat("a", 60) + "beef", Account: "real@visionA.cloud", RelayURL: "wss://relay.visionA.cloud/tunnel/connect", }, }) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err != nil { t.Fatalf("Exchange: %v", err) } if !strings.HasPrefix(r.SessionToken, "vAs_") { t.Errorf("SessionToken = %q", r.SessionToken) } if r.Account != "real@visionA.cloud" { t.Errorf("Account = %q", r.Account) } if r.RelayURL != "wss://relay.visionA.cloud/tunnel/connect" { t.Errorf("RelayURL = %q", r.RelayURL) } } // TestExchangeRealRawEnvelope 用手寫 JSON 字串(複製 visionA-backend WriteSuccess 的 // 真實輸出形狀)驗證 agent 能正確從 data. 那層解出欄位,不依賴 agent 自己的 struct // 編碼(避免 struct 改錯時測試跟著錯、形成假綠)。 func TestExchangeRealRawEnvelope(t *testing.T) { const rawBody = `{"success":true,"data":{"session_token":"vAs_deadbeef","account":"raw@visionA.local","relay_url":"wss://raw.relay/tunnel/connect","expires_at":"2026-07-21T00:00:00Z"}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(rawBody)) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err != nil { t.Fatalf("Exchange against raw cloud envelope: %v", err) } if r.SessionToken != "vAs_deadbeef" { t.Errorf("SessionToken = %q, want vAs_deadbeef (from data.session_token)", r.SessionToken) } if r.Account != "raw@visionA.local" { t.Errorf("Account = %q, want raw@visionA.local (from data.account)", r.Account) } if r.RelayURL != "wss://raw.relay/tunnel/connect" { t.Errorf("RelayURL = %q, want from data.relay_url", r.RelayURL) } } // TestExchangeRealTopLevelSessionTokenRejected 是回歸防護:確認 agent 不會誤接 // 「session_token 在頂層」的舊格式(contract drift 前的假設)。雲端不再回這種格式, // 若 agent 又退回解頂層,這個測試會抓到(會解不到 data.session_token → 報 missing)。 func TestExchangeRealTopLevelSessionTokenRejected(t *testing.T) { const legacyBody = `{"session_token":"vAs_toplevel","account":"old@x"}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(legacyBody)) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err == nil { t.Fatal("expected error when session_token is only at top level (legacy format no longer accepted)") } if !strings.Contains(err.Error(), "missing session_token") { t.Errorf("err = %v, want 'missing session_token'", err) } } // TestExchangeReal401Codes 驗證 agent 能從雲端錯誤 envelope({success:false, error:{code}}) // 解出大寫 error code(INVALID_PAIRING_TOKEN 等,對齊 api.ErrCode* 常數)→ 映射成 // 對應 sentinel error。 func TestExchangeReal401Codes(t *testing.T) { cases := []struct { code string // 雲端大寫常數(api.ErrCodeInvalidPairingToken 等) wantErr error }{ {"INVALID_PAIRING_TOKEN", ErrTokenInvalid}, {"PAIRING_TOKEN_EXPIRED", ErrTokenExpired}, {"PAIRING_TOKEN_USED", ErrTokenUsed}, {"PAIRING_TOKEN_REVOKED", ErrTokenRevoked}, } for _, tc := range cases { t.Run(tc.code, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) // 雲端真實格式:{success:false, error:{code, message, request_id}}。 _ = json.NewEncoder(w).Encode(exchangeErrorResponse{ Success: false, Error: exchangeErrorDetail{ Code: tc.code, Message: "pairing token rejected", }, }) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if !errors.Is(err, tc.wantErr) { t.Errorf("err = %v, want %v", err, tc.wantErr) } }) } } // TestExchangeReal401RawEnvelope 用手寫 JSON(複製 WriteError 真實輸出)驗證 401 // envelope 解析,不依賴 agent struct 編碼。 func TestExchangeReal401RawEnvelope(t *testing.T) { const rawErr = `{"success":false,"error":{"code":"PAIRING_TOKEN_EXPIRED","message":"pairing token expired","request_id":"req_123"}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) _, _ = w.Write([]byte(rawErr)) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if !errors.Is(err, ErrTokenExpired) { t.Errorf("err = %v, want ErrTokenExpired (from error.code=PAIRING_TOKEN_EXPIRED)", err) } } // TestExchangeReal401TopLevelCodeRejected 回歸防護:舊格式(頂層小寫 code)不應再被 // 接受。雲端不回這種格式;若 agent 退回解頂層 token_invalid,會落到 default 分支 // (仍是 ErrTokenInvalid,但帶 code=""),用 error 訊息含空 code 確認沒解到。 func TestExchangeReal401TopLevelCodeRejected(t *testing.T) { const legacyErr = `{"code":"token_expired"}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) _, _ = w.Write([]byte(legacyErr)) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") // 頂層 code 解不到 error.code → 落 default(fmt 包 ErrTokenInvalid + code="")。 // 重點:不應被誤判成 ErrTokenExpired。 if errors.Is(err, ErrTokenExpired) { t.Errorf("legacy top-level code should NOT map to ErrTokenExpired; got %v", err) } if !errors.Is(err, ErrTokenInvalid) { t.Errorf("unmatched code should fall back to ErrTokenInvalid; got %v", err) } } // TestIsPairingMockOptIn 驗證 Fix-A3:mock 模式必須明確 opt-in。 // // 預設規則(任何不是明確 "true" 的值)一律走真實 exchange,避免「使用者忘設環境 // 變數,看起來能跑但其實沒打 backend」這個隱形災難。 func TestIsPairingMockOptIn(t *testing.T) { cases := []struct { env string want bool desc string }{ {"", false, "unset → real (production-safe default)"}, {"true", true, "明確 true → mock"}, {"True", true, "大小寫不敏感"}, {"TRUE", true, "全大寫"}, {"false", false, "明確 false → real"}, {"False", false, "false 大小寫不敏感"}, {"1", false, "1 不是 true → real(避免拼錯字啟用 mock)"}, {"yes", false, "yes 不是 true → real"}, {"on", false, "on 不是 true → real"}, {" true", false, "前導空白 → 不識別為 true(規則嚴格)"}, {"truthy", false, "包含 true 的字串不算"}, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { got := IsPairingMockOptIn(tc.env) if got != tc.want { t.Errorf("IsPairingMockOptIn(%q) = %v, want %v", tc.env, got, tc.want) } }) } } // TestIsInsecureSkipTLSVerify 驗證跳過 TLS 驗證的 env 解析(必須明確 opt-in)。 // // 預設規則(任何不是明確 "true" 的值)一律維持安全的 TLS 驗證,避免使用者拼錯字 // 或殘留環境變數誤入不安全模式。 func TestIsInsecureSkipTLSVerify(t *testing.T) { cases := []struct { env string want bool desc string }{ {"", false, "unset → 安全驗證(production-safe default)"}, {"true", true, "明確 true → skip"}, {"True", true, "大小寫不敏感"}, {"TRUE", true, "全大寫"}, {"false", false, "明確 false → 安全驗證"}, {"False", false, "false 大小寫不敏感"}, {"1", false, "1 不是 true → 安全驗證(避免拼錯字啟用)"}, {"yes", false, "yes 不是 true → 安全驗證"}, {"on", false, "on 不是 true → 安全驗證"}, {" true", false, "前導空白 → 不識別為 true(規則嚴格)"}, {"truthy", false, "包含 true 的字串不算"}, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { got := IsInsecureSkipTLSVerify(tc.env) if got != tc.want { t.Errorf("IsInsecureSkipTLSVerify(%q) = %v, want %v", tc.env, got, tc.want) } }) } } // TestExchangerInsecureSkipTLSVerifyAppliesToClient 驗證開關開時,預設建構的 // http.Client 的 Transport.TLSClientConfig.InsecureSkipVerify == true。 func TestExchangerInsecureSkipTLSVerifyAppliesToClient(t *testing.T) { // 開關開:用一個會拒絕未知 CA 的 TLS server,確認沒有 x509 錯誤即代表已跳過驗證。 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // 雲端真實格式:{success:true, data:{...}}。 _ = json.NewEncoder(w).Encode(exchangeResponse{ Success: true, Data: exchangeResponseData{ SessionToken: "vAs_" + strings.Repeat("a", 64), Account: "skip@visionA.local", }, }) })) defer srv.Close() // httptest TLS server 用自簽憑證,預設 client 會 x509 失敗;開關開應成功。 ex := NewHTTPPairingExchanger(srv.URL) ex.InsecureSkipTLSVerify = true r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err != nil { t.Fatalf("Exchange with InsecureSkipTLSVerify=true should succeed against self-signed server, got: %v", err) } if r.Account != "skip@visionA.local" { t.Errorf("Account = %q", r.Account) } } // TestExchangerSecureByDefaultRejectsSelfSigned 驗證開關關(預設)時,連自簽憑證 // server 會因 TLS 驗證失敗而報 network error(不會跳過驗證)。 func TestExchangerSecureByDefaultRejectsSelfSigned(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) // InsecureSkipTLSVerify 預設 false _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err == nil { t.Fatal("expected TLS verification error against self-signed server when skip disabled") } if !errors.Is(err, ErrExchangeNetwork) { t.Errorf("err = %v, want wrapped ErrExchangeNetwork (TLS x509 failure)", err) } } func TestExchangeReal404HintsMockMode(t *testing.T) { // 模擬 AB11 尚未上線時 endpoint 不存在的情境 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })) defer srv.Close() ex := NewHTTPPairingExchanger(srv.URL) _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef") if err == nil { t.Fatal("expected error on 404") } if !strings.Contains(err.Error(), "mock_mode") && !strings.Contains(err.Error(), "AB11") { t.Errorf("err = %v — should hint mock_mode or AB11", err) } }