- 新增 VISIONA_INSECURE_SKIP_TLS_VERIFY(DEV/TEST ONLY、須明確 opt-in "true"): pairing exchange HTTP client、tunnel WSS dialer、設定頁 TestConnection 三路徑 共用 TLSConfigForDial(含 ALPN 釘 http/1.1);NewApp 唯一 env 讀取點注入欄位 - exchangeResponse 對齊雲端 /api/pairing/exchange success envelope (account/relay_url 選填 fallback 保留、舊頂層格式回歸防護) - .gitignore:.env.stage* + !.env.stage.example + *.pptx(堵 secrets 誤入) - start-agent.sh(新增):public 模式 export skip env + 預檢 curl https 帶 -k - 測試:自簽 TLS server 行為級(預設拒絕驗 x509 / 開啟通過)+ opt-in 規則 11+5 案例 + TestConnection 3 測試;go build/vet/test 3 packages 全綠 - review:2 輪通過(.autoflow/05-implementation/review/tls-skip-uncommitted-batch-review.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
377 lines
14 KiB
Go
377 lines
14 KiB
Go
// 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)
|
||
}
|
||
}
|