從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
5.8 KiB
Go
195 lines
5.8 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")
|
||
_ = 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)
|
||
}
|
||
}
|