jim800121chen 3d30fdc580 feat(local-agent): TLS skip opt-in for self-signed stage + exchange envelope 對齊
- 新增 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>
2026-07-10 09:01:10 +08:00

377 lines
14 KiB
Go
Raw 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.

// 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 codeINVALID_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 → 落 defaultfmt 包 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-A3mock 模式必須明確 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)
}
}