visionA/visionA-backend/cmd/api-server/e2e_full_flow_test.go
jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

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

// e2e_full_flow_test.go — AB13把 pairing exchange + tunnel connect + API forward
// 三段串成單一端到端測試,驗證雲端版完整鏈路。
//
// 這個 test 是雛形交付前的最終驗收 — 通過代表:
//
// 使用者在 agent 貼上 pairing token
// │
// ▼
// Agent 呼叫 POST /api/pairing/exchangeapi-server
// → 拿到 Session Token + Relay URL
// ▼
// Agent 用 Session Token 對 remote-proxy 的 /tunnel/connect 建 WebSocket
// → yamux session 註冊進 SessionStore
// ▼
// 前端打 GET /api/devices/scanapi-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 moduleagent 又依賴 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.ClienthandleStream 用 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 subprocessgo test 裡 exec.Command("go", "run", ...) 在 CI
// 上不穩port 競爭、cleanup race、跨 module build且測試時間會從秒級
// 拉到分鐘級。subprocess 方案我們另外提供為 manual scriptscripts/
// e2e-manual-test.sh給使用者要真驗證時跑。
//
// 參考:
// - .autoflow/04-architecture/visiona-agent-tdd.md §11integration / e2e testing
// - .autoflow/04-architecture/tunnel.md §3資料流
// - b5_integration_test.go / pairing_exchange_test.go既有整合測試基礎
// - local-agent/visiona-agent/internal/tunnel/integration_test.goagent 端鏡像測試)
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 TokenOIDC 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.ClientWS+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=trueapi-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 會正確
// 回 502TUNNEL_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 clientagent 崩潰)
stop()
// 等 session 被清出 storeWS 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"])
}