// 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") _ = json.NewEncoder(w).Encode(exchangeResponse{ 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) } } func TestExchangeReal401Codes(t *testing.T) { cases := []struct { code string wantErr error }{ {"token_invalid", ErrTokenInvalid}, {"token_expired", ErrTokenExpired}, {"token_used", ErrTokenUsed}, {"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) _ = json.NewEncoder(w).Encode(map[string]string{"code": tc.code}) })) 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) } }) } } // 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) } }) } } 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) } }