// e2e_full_flow_test.go — AB13:把 pairing exchange + tunnel connect + API forward // 三段串成單一端到端測試,驗證雲端版完整鏈路。 // // 這個 test 是雛形交付前的最終驗收 — 通過代表: // // 使用者在 agent 貼上 pairing token // │ // ▼ // Agent 呼叫 POST /api/pairing/exchange(api-server) // → 拿到 Session Token + Relay URL // ▼ // Agent 用 Session Token 對 remote-proxy 的 /tunnel/connect 建 WebSocket // → yamux session 註冊進 SessionStore // ▼ // 前端打 GET /api/devices/scan(api-server) // → api-server.Forwarder.ForwardHTTP // → remote-proxy.handleInternalForward (hijack + yamux OpenStream) // → agent.handleStream (RoundTrip to local-tool) // → local-tool 回 JSON // → 逐段原封轉回前端 // // 設計取捨: // // - 為何不 cross-module import agent 原始碼: // visionA-backend 與 visiona-agent 是獨立 go module,agent 又依賴 wails/v2 // (會把 Wails 的 UI 層傳遞依賴全拖進 backend 的 go.sum)。為了保持 backend // 的依賴乾淨,我們用 b5_integration_test.go 早已驗證過的 startFakeTunnelClient // —— 它用純 gorilla/websocket + yamux.Client 重現 agent 的 tunnel 邏輯,在 // 協議面上與 agent 的 tunnel.Client 等價(agent 的 Client 也是在 WS 上跑 // yamux.Client,handleStream 用 http.ReadRequest / RoundTrip / resp.Write)。 // // 真正的 agent 程式碼路徑(tunnel.Client.handleStream)已由 AB6 的 // internal/tunnel/integration_test.go 用同樣的 fake relay 模式驗證過; // 那邊用的是真 Manager + fake relay,這邊用的是真 backend + fake tunnel // client。兩者覆蓋的是鏡像路徑,合起來 = 完整 e2e。 // // - 為何不 spawn subprocess:go test 裡 exec.Command("go", "run", ...) 在 CI // 上不穩(port 競爭、cleanup race、跨 module build),且測試時間會從秒級 // 拉到分鐘級。subprocess 方案我們另外提供為 manual script(scripts/ // e2e-manual-test.sh),給使用者要真驗證時跑。 // // 參考: // - .autoflow/04-architecture/visiona-agent-tdd.md §11(integration / e2e testing) // - .autoflow/04-architecture/tunnel.md §3(資料流) // - b5_integration_test.go / pairing_exchange_test.go(既有整合測試基礎) // - local-agent/visiona-agent/internal/tunnel/integration_test.go(agent 端鏡像測試) package main import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/api" "visiona-backend/internal/auth" ) // TestE2E_FullFlow_PairingToForward 是 AB13 的核心驗收測試。 // // 串起 pairing exchange → tunnel connect → API forward 三個里程碑,確認 // 整條雲端版架構在同一個 test run 裡能跑通。 // // 這個 test 跟 TestAB11_PairingExchange_EndToEnd 的差別:AB11 驗到 tunnel // connect 進 store 就停,這裡往下多走一段「打 API → forward 回 fake local」, // 覆蓋 B5 forwarder handler 真實被呼叫的路徑。 func TestE2E_FullFlow_PairingToForward(t *testing.T) { // ----------------------------------------------------------------- // 1. fake local-tool(模擬 agent 背後的 local HTTP server) // ----------------------------------------------------------------- // 對 /api/devices/scan 回一段 JSON,驗證 request 真的穿過整條 tunnel。 // 同時 echo 出 X-Forwarded-For / X-Request-ID 之類的 header 供驗證。 localCalls := 0 localHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { localCalls++ // 驗證進來的 request 是我們預期的(證明 host rewrite 正確) if r.URL.Path != "/api/devices/scan" { http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) return } if r.Method != http.MethodPost { http.Error(w, "unexpected method: "+r.Method, http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Backend-Source", "e2e-fake-local") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]any{ "scanned": 2, "devices": []map[string]any{ {"id": "kl520-e2e-01", "type": "kl520", "status": "online"}, {"id": "kl730-e2e-02", "type": "kl730", "status": "online"}, }, }) }) f := setupFixture(t, localHandler) defer f.Close() authClient := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // ----------------------------------------------------------------- // Milestone 1: Pairing Exchange // ----------------------------------------------------------------- // 1a. 產 Pairing Token(OIDC cookie 放行 AuthMiddleware) tokResp, err := authClient.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) defer tokResp.Body.Close() require.Equal(t, http.StatusOK, tokResp.StatusCode) var tokBody map[string]any require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) pairingTok := tokBody["data"].(map[string]any)["token"].(string) require.True(t, auth.IsValidPairingToken(pairingTok), "Milestone 1a: pairing token 格式應合法,實得 %q", pairingTok) // 1b. 用 Pairing Token 換 Session Token(不走 AuthMiddleware) exchBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(exchBody)) require.NoError(t, err) defer exchResp.Body.Close() exchRaw, _ := io.ReadAll(exchResp.Body) require.Equal(t, http.StatusOK, exchResp.StatusCode, "Milestone 1b: exchange 應成功,body: %s", string(exchRaw)) var exchBodyDecoded map[string]any require.NoError(t, json.Unmarshal(exchRaw, &exchBodyDecoded)) exchData := exchBodyDecoded["data"].(map[string]any) sessionTok := exchData["session_token"].(string) require.True(t, auth.IsValidSessionToken(sessionTok), "Milestone 1b: session token 格式應合法,實得 %q", sessionTok) assert.NotEmpty(t, exchData["relay_url"], "relay_url 應由 api-server 回傳給 agent") assert.NotEmpty(t, exchData["account"], "account 應由 api-server 回傳給 agent") assert.NotEmpty(t, exchData["expires_at"], "expires_at 應由 api-server 回傳給 agent") // ----------------------------------------------------------------- // Milestone 2: Tunnel Connect // ----------------------------------------------------------------- // 用剛換到的 Session Token 對 remote-proxy 的 tunnel endpoint 建連線。 // startFakeTunnelClient 的協議行為 = agent 的 tunnel.Client(WS+yamux+handleStream)。 stop := startFakeTunnelClient(t, f.tunnelSrv.URL, sessionTok, strings.TrimPrefix(f.localBackend.URL, "http://")) defer stop() // 等 session 實際註冊進 remote-proxy 的 InMemoryStore // (WS handshake + yamux client up 是非同步的) require.Eventually(t, func() bool { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() ok, _ := f.store.Exists(ctx, sessionTok) return ok }, 3*time.Second, 20*time.Millisecond, "Milestone 2: session token 應在 3 秒內出現在 SessionStore") // 2b. 同步驗證 /api/system/health 看 api-server 也能透過 ProxyClient 讀到 session healthResp, err := authClient.Get(f.apiServer.URL + "/api/system/health") require.NoError(t, err) defer healthResp.Body.Close() require.Equal(t, http.StatusOK, healthResp.StatusCode) var healthBody map[string]any require.NoError(t, json.NewDecoder(healthResp.Body).Decode(&healthBody)) healthData := healthBody["data"].(map[string]any) assert.Equal(t, true, healthData["tunnel_connected"], "Milestone 2b: /api/system/health 應回 tunnel_connected=true(api-server → remote-proxy 讀 session)") assert.EqualValues(t, 1, healthData["agent_session_count"], "Milestone 2b: 應有 1 個 agent session") // ----------------------------------------------------------------- // Milestone 3: API Forward(完整鏈路) // ----------------------------------------------------------------- // 這是最終驗證:browser → api-server → Forwarder → remote-proxy // → yamux stream → fake tunnel client → fake local-tool // 任何一環出錯都會 fail。 scanResp, err := authClient.Post(f.apiServer.URL+"/api/devices/scan", "application/json", nil) require.NoError(t, err, "Milestone 3: scan 應能 forward 成功") defer scanResp.Body.Close() require.Equal(t, http.StatusOK, scanResp.StatusCode, "Milestone 3: scan 應回 200,實際 %d", scanResp.StatusCode) var scanBody map[string]any require.NoError(t, json.NewDecoder(scanResp.Body).Decode(&scanBody)) assert.EqualValues(t, 2, scanBody["scanned"], "Milestone 3: response body 應原封穿過 tunnel 回來") devices := scanBody["devices"].([]any) require.Len(t, devices, 2, "Milestone 3: 應收到 2 個 device") // 驗證 header 也穿過來了(Forwarder 會保留 upstream response header) assert.Equal(t, "e2e-fake-local", scanResp.Header.Get("X-Backend-Source"), "Milestone 3: fake local 的 response header 應被轉回來") assert.Equal(t, 1, localCalls, "Milestone 3: fake local 應被呼叫一次(證明 request 真的走到底)") // ----------------------------------------------------------------- // Milestone 4: 重複兌換保護 // ----------------------------------------------------------------- // 同一個 pairing token 再換一次應該被拒絕(PAIRING_TOKEN_USED), // 避免有人竊聽到 pairing token 時能重放。 exchResp2, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(exchBody)) require.NoError(t, err) defer exchResp2.Body.Close() assert.Equal(t, http.StatusUnauthorized, exchResp2.StatusCode, "Milestone 4: 重複兌換應 401") body2, _ := io.ReadAll(exchResp2.Body) assert.Contains(t, string(body2), "PAIRING_TOKEN_USED", "Milestone 4: 錯誤碼應為 PAIRING_TOKEN_USED") } // TestE2E_ForwardFailsWhenTunnelDropped 驗證 tunnel 斷線後 API forward 會正確 // 回 502(TUNNEL_DISCONNECTED)。這模擬 agent 端進程崩潰 / 網路中斷後雲端的 // 反應,對齊 TDD §11.2 failure mode 清單。 // // 流程: // 1. 建立完整 e2e 鏈路(exchange → connect → forward 一次成功) // 2. 關掉 fake tunnel client(模擬 agent 崩潰) // 3. 等 session 從 store 消失 // 4. 再打 /api/devices/scan → 預期 502 TUNNEL_DISCONNECTED func TestE2E_ForwardFailsWhenTunnelDropped(t *testing.T) { localHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"scanned": 0}) }) f := setupFixture(t, localHandler) defer f.Close() authClient := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local") // 1. Exchange → Session Token tokResp, err := authClient.Post(f.apiServer.URL+"/api/pairing/token", "", nil) require.NoError(t, err) var tokBody map[string]any require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) tokResp.Body.Close() pairingTok := tokBody["data"].(map[string]any)["token"].(string) exchReqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", "application/json", bytes.NewReader(exchReqBody)) require.NoError(t, err) var exchBodyDecoded map[string]any require.NoError(t, json.NewDecoder(exchResp.Body).Decode(&exchBodyDecoded)) exchResp.Body.Close() sessionTok := exchBodyDecoded["data"].(map[string]any)["session_token"].(string) // 2. 建 tunnel + 先 forward 一次確認鏈路通 stop := startFakeTunnelClient(t, f.tunnelSrv.URL, sessionTok, strings.TrimPrefix(f.localBackend.URL, "http://")) require.Eventually(t, func() bool { ok, _ := f.store.Exists(context.Background(), sessionTok) return ok }, 3*time.Second, 20*time.Millisecond) firstResp, err := authClient.Post(f.apiServer.URL+"/api/devices/scan", "application/json", nil) require.NoError(t, err) require.Equal(t, http.StatusOK, firstResp.StatusCode, "前置條件:第一次 forward 應成功,代表鏈路有建起") firstResp.Body.Close() // 3. 關掉 fake tunnel client(agent 崩潰) stop() // 等 session 被清出 store(WS close → relay 偵測到 → remove session) require.Eventually(t, func() bool { ok, _ := f.store.Exists(context.Background(), sessionTok) return !ok }, 3*time.Second, 50*time.Millisecond, "session 應在 tunnel 斷線後從 store 消失") // 4. 再 forward 應 502 secondResp, err := authClient.Post(f.apiServer.URL+"/api/devices/scan", "application/json", nil) require.NoError(t, err) defer secondResp.Body.Close() assert.Equal(t, http.StatusBadGateway, secondResp.StatusCode, "tunnel 斷線後 forward 應回 502") var errBody map[string]any require.NoError(t, json.NewDecoder(secondResp.Body).Decode(&errBody)) errObj := errBody["error"].(map[string]any) assert.Equal(t, "TUNNEL_DISCONNECTED", errObj["code"]) }