package main // agent_bindings_test.go — AB8-AB10 binding 層測試。 // // 不啟動 Wails runtime,只驗證 binding 的輸入 / 輸出 / 錯誤處理。 // // 覆蓋項目: // - GetAgentSettings / SaveAgentSettings 的 config store 串接 // - TestConnection 的輸入驗證(不實際上網) // - ResetAllSettings 同時清 token + config // - ExportLog 產出合法 zip(內含 ring-buffer.txt) // - agentConfigToSettings / settingsToAgentConfig 互轉不掉資訊 import ( "archive/zip" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "visiona-agent/internal/agentconfig" "visiona-agent/internal/tunnel" "github.com/gorilla/websocket" ) // newBindingTestApp 建立一個沒有 Wails runtime 的 App,只帶 AB7-AB10 會用到的元件。 func newBindingTestApp(t *testing.T) *App { t.Helper() dir := t.TempDir() a := &App{dataDir: dir} a.logBuf = NewLogBuffer() store, err := agentconfig.NewStore(dir, nil) if err != nil { t.Fatalf("NewStore: %v", err) } a.configStore = store ts, err := tunnel.NewEncryptedFileTokenStore(dir, nil) if err != nil { t.Fatalf("NewEncryptedFileTokenStore: %v", err) } a.tokenStore = ts return a } func TestGetAgentSettings_WithoutStoreReturnsDefaults(t *testing.T) { a := &App{} got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings without store should not error; got %v", err) } if got.RelayURL != agentconfig.DefaultRelayURL { t.Errorf("RelayURL = %q; want %q", got.RelayURL, agentconfig.DefaultRelayURL) } if got.ReconnectStrategy != ReconnectStrategyAuto { t.Errorf("ReconnectStrategy = %q; want auto", got.ReconnectStrategy) } } func TestSaveAgentSettings_RoundtripThroughConfigStore(t *testing.T) { a := newBindingTestApp(t) newSettings := AgentSettings{ RelayURL: "wss://custom.relay.example.com/tunnel", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug", } if err := a.SaveAgentSettings(newSettings); err != nil { t.Fatalf("SaveAgentSettings: %v", err) } got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings: %v", err) } if got != newSettings { t.Errorf("Get after Save = %+v; want %+v", got, newSettings) } } func TestSaveAgentSettings_RejectsInvalidRelayURL(t *testing.T) { a := newBindingTestApp(t) bad := AgentSettings{ RelayURL: "http://not-websocket.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info", } if err := a.SaveAgentSettings(bad); err == nil { t.Error("SaveAgentSettings should reject http:// URL") } } func TestSaveAgentSettings_WithoutStoreReturnsNotReady(t *testing.T) { a := &App{} err := a.SaveAgentSettings(AgentSettings{ RelayURL: "wss://relay.example.com", ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info", }) if err == nil { t.Error("SaveAgentSettings without store should error") } } func TestTestConnection_InputValidation(t *testing.T) { a := &App{} cases := []struct { name string url string wantOK bool wantRea string }{ {"empty", "", false, "empty"}, {"no scheme", "relay.example.com", false, "ws://"}, {"http scheme", "http://relay.example.com", false, "ws://"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := a.TestConnection(c.url) if got.OK != c.wantOK { t.Errorf("OK = %v; want %v (reason=%q)", got.OK, c.wantOK, got.Reason) } if c.wantRea != "" && !strings.Contains(got.Reason, c.wantRea) { t.Errorf("Reason %q should mention %q", got.Reason, c.wantRea) } }) } } // newSelfSignedWSServer 起一個自簽憑證的 TLS server,收到請求就完成 WebSocket // upgrade 後立即關閉(TestConnection 只測握手,不發 frame)。 // 回傳 wss:// URL。 func newSelfSignedWSServer(t *testing.T) string { t.Helper() upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := upgrader.Upgrade(w, r, nil) if err != nil { return // upgrade 失敗(非 WS 請求);TestConnection 端會收到 dial error } _ = c.Close() })) t.Cleanup(srv.Close) return "wss://" + strings.TrimPrefix(srv.URL, "https://") } // TestTestConnection_SecureByDefaultRejectsSelfSigned 驗證預設(未 opt-in skip)時, // TestConnection 對自簽憑證 server 應以 TLS 驗證失敗收場(production-safe default)。 // 比照 tunnel package 的行為級測試模式(TestExchangerSecureByDefaultRejectsSelfSigned)。 func TestTestConnection_SecureByDefaultRejectsSelfSigned(t *testing.T) { wssURL := newSelfSignedWSServer(t) a := &App{} // insecureSkipTLSVerify 預設 false got := a.TestConnection(wssURL) if got.OK { t.Fatal("TestConnection should fail against self-signed server when skip disabled") } // 失敗原因必須是憑證驗證(x509),不是其他網路錯誤——確保真的走到 TLS 驗證。 if !strings.Contains(got.Reason, "x509") && !strings.Contains(got.Reason, "certificate") { t.Errorf("Reason = %q, want TLS certificate verification failure (x509)", got.Reason) } } // TestTestConnection_InsecureSkipTLSVerifyAllowsSelfSigned 驗證開關開時, // TestConnection 對自簽憑證 server 應握手成功(stage demo 情境;Major-1 修復的行為)。 func TestTestConnection_InsecureSkipTLSVerifyAllowsSelfSigned(t *testing.T) { wssURL := newSelfSignedWSServer(t) a := &App{insecureSkipTLSVerify: true} got := a.TestConnection(wssURL) if !got.OK { t.Fatalf("TestConnection with skip enabled should succeed against self-signed server, got reason=%q", got.Reason) } } // TestNewApp_InsecureSkipTLSVerifyOptIn 驗證 NewApp(唯一 env 讀取點)遵守 // opt-in 規則:只有明確 "true" 才開,unset / false / 拼錯字都維持安全預設。 // (完整 11 案例的規則測試在 tunnel.IsInsecureSkipTLSVerify 的單元測試; // 這裡只守「NewApp 有正確接上同一份判斷」。) func TestNewApp_InsecureSkipTLSVerifyOptIn(t *testing.T) { cases := []struct { name string env string want bool }{ {"unset(安全預設)", "", false}, {"false", "false", false}, {"拼錯字 truthy", "truthy", false}, {"明確 opt-in true", "true", true}, {"大小寫不敏感 TRUE", "TRUE", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Setenv(tunnel.InsecureSkipTLSVerifyEnvVar, tc.env) a := NewApp() if a.insecureSkipTLSVerify != tc.want { t.Errorf("NewApp().insecureSkipTLSVerify = %v, want %v (env=%q)", a.insecureSkipTLSVerify, tc.want, tc.env) } }) } } func TestResetAllSettings_ClearsConfigAndToken(t *testing.T) { a := newBindingTestApp(t) // 先寫入非預設 settings + 一個 token custom := AgentSettings{ RelayURL: "wss://custom.example.com/tunnel", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug", } if err := a.SaveAgentSettings(custom); err != nil { t.Fatalf("SaveAgentSettings: %v", err) } if err := a.tokenStore.Save("vAs_test_token"); err != nil { t.Fatalf("Save token: %v", err) } if err := a.ResetAllSettings(); err != nil { t.Fatalf("ResetAllSettings: %v", err) } // Settings 應回到預設 got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings: %v", err) } if got.RelayURL != agentconfig.DefaultRelayURL { t.Errorf("after reset RelayURL = %q; want default %q", got.RelayURL, agentconfig.DefaultRelayURL) } // Token 應被清除(Unpair 有呼叫 tokenStore.Delete) // 但因為 tunnelManager == nil,Unpair 這條路不走;手動驗證 tokenStore // 本身在 ResetAllSettings 流程中沒被 Manager 清——這個測試只確認 config 重置。 // token 清除由 Manager.Unpair() 負責(見 TestResetAllSettings_WithManager)。 } func TestExportLog_ProducesValidZip(t *testing.T) { a := newBindingTestApp(t) // 寫幾行 log 到 ring buffer for i, line := range []string{"[INFO] first line", "[ERROR] oh no", "plain text"} { a.logBuf.Append(LogLine{ Ts: int64(1700000000000 + i), Stream: "test", Line: line, Level: parseLogLevel(line), }) } path, err := a.ExportLog() if err != nil { t.Fatalf("ExportLog: %v", err) } defer os.Remove(path) if !strings.HasSuffix(path, ".zip") { t.Errorf("ExportLog path = %q; want .zip suffix", path) } // 打開 zip 驗證內容 zr, err := zip.OpenReader(path) if err != nil { t.Fatalf("open zip: %v", err) } defer zr.Close() var foundRingBuffer bool for _, f := range zr.File { if f.Name == "ring-buffer.txt" { foundRingBuffer = true rc, err := f.Open() if err != nil { t.Fatalf("open ring-buffer.txt: %v", err) } data, err := io.ReadAll(rc) _ = rc.Close() if err != nil { t.Fatalf("read ring-buffer.txt: %v", err) } s := string(data) if !strings.Contains(s, "first line") { t.Errorf("ring-buffer.txt missing 'first line'; got:\n%s", s) } if !strings.Contains(s, "[error]") && !strings.Contains(s, "[ERROR]") { t.Errorf("ring-buffer.txt should include level; got:\n%s", s) } } } if !foundRingBuffer { t.Error("zip should contain ring-buffer.txt") } } func TestExportLog_WithoutLogBufReturnsError(t *testing.T) { a := &App{} if _, err := a.ExportLog(); err == nil { t.Error("ExportLog without logBuf should error") } } func TestAgentConfigSettings_RoundtripPreservesAllFields(t *testing.T) { cases := []AgentSettings{ {RelayURL: "wss://a.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info"}, {RelayURL: "ws://b.example.com:1234", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug"}, {RelayURL: "wss://c.example.com", AutoStart: true, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "warn"}, {RelayURL: "wss://d.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "error"}, } for _, in := range cases { cfg := settingsToAgentConfig(in) out := agentConfigToSettings(cfg) if out != in { t.Errorf("roundtrip:\n in = %+v\n out = %+v", in, out) } } } func TestGetRecentLogs_RespectsLimit(t *testing.T) { a := newBindingTestApp(t) for i := 0; i < 10; i++ { a.logBuf.Append(LogLine{Ts: int64(i), Line: "line"}) } got := a.GetRecentLogs(3) if len(got) != 3 { t.Errorf("GetRecentLogs(3) len = %d; want 3", len(got)) } // 應該是最新 3 筆(index 7, 8, 9) if got[0].Ts != 7 || got[2].Ts != 9 { t.Errorf("GetRecentLogs(3) ts = [%d, _, %d]; want [7, _, 9]", got[0].Ts, got[2].Ts) } } func TestGetRecentLogs_WithoutBufferReturnsEmpty(t *testing.T) { a := &App{} got := a.GetRecentLogs(10) if len(got) != 0 { t.Errorf("GetRecentLogs without buffer = %d items; want 0", len(got)) } } // TestMaskPairingToken 驗證 pairing token 的 log 遮罩。 // // 這是安全相關 helper:遮罩錯就會把敏感的 pairing token 全文寫進 wails.log。 // 規則(見 maskPairingToken): // - 正常 token(vAc_ + 32 hex)→ "vAc_" + 前 4 hex + "…"(其餘以 "…" 取代) // - 過短 / 非 vAc_ 前綴 / 空字串 → ""(只記長度、不記內容) func TestMaskPairingToken(t *testing.T) { const fullToken = "vAc_0123456789abcdef0123456789abcdef" // vAc_ + 32 hex // suffix 是「不該出現在遮罩結果」的尾段(前 4 hex 之後的部分)。 const leakSuffix = "456789abcdef0123456789abcdef" cases := []struct { name string in string want string }{ {"normal token", fullToken, "vAc_0123…"}, {"min valid length (vAc_ + 4 hex)", "vAc_0123", "vAc_0123…"}, {"too short (vAc_ + 3 hex)", "vAc_012", ""}, {"empty string", "", ""}, {"wrong prefix (session token)", "vAs_0123456789abcdef0123456789abcdef", ""}, {"no prefix", "0123456789abcdef0123456789abcdef", ""}, {"prefix only", "vAc_", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := maskPairingToken(c.in) if got != c.want { t.Errorf("maskPairingToken(%q) = %q; want %q", c.in, got, c.want) } }) } // 額外的安全不變量:正常 token 的遮罩結果絕不能包含 token 全文或其尾段。 masked := maskPairingToken(fullToken) if strings.Contains(masked, fullToken) { t.Errorf("masked output %q must not contain the full token", masked) } if strings.Contains(masked, leakSuffix) { t.Errorf("masked output %q must not leak the token suffix %q", masked, leakSuffix) } // 正確前綴必須保留(供對照)。 if !strings.HasPrefix(masked, "vAc_") { t.Errorf("masked output %q should keep the vAc_ prefix for correlation", masked) } }