visionA/local-agent/visiona-agent/agent_bindings_test.go
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

387 lines
13 KiB
Go
Raw Permalink 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.

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 == nilUnpair 這條路不走;手動驗證 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
// - 正常 tokenvAc_ + 32 hex→ "vAc_" + 前 4 hex + "…"(其餘以 "…" 取代)
// - 過短 / 非 vAc_ 前綴 / 空字串 → "<malformed len=N>"(只記長度、不記內容)
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", "<malformed len=7>"},
{"empty string", "", "<malformed len=0>"},
{"wrong prefix (session token)", "vAs_0123456789abcdef0123456789abcdef", "<malformed len=36>"},
{"no prefix", "0123456789abcdef0123456789abcdef", "<malformed len=32>"},
{"prefix only", "vAc_", "<malformed len=4>"},
}
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)
}
}