從 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>
290 lines
13 KiB
Go
290 lines
13 KiB
Go
// 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"])
|
||
}
|