- 新增 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>
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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_ 前綴 / 空字串 → "<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)
|
||
}
|
||
}
|