visionA/visionA-backend/cmd/api-server/integration_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

527 lines
19 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.

// integration_test.go — B4 完整端對端測試。
//
// 驗證雛形雙 binary 架構能跑通:
//
// prog test (HTTP client)
// └─► api-server: GET /api/system/health, /api/pairing/status
// └─► (system/health 內部呼叫 SessionStore.List → ProxyClient → remote-proxy)
// └─► remote-proxy: /internal/sessions
// └─► InMemoryStorefake tunnel client 已註冊一個 session
//
// 以及驗證 forwarder 能完成 raw forward
//
// api-server (Forwarder.OpenStream)
// └─► remote-proxy: POST /internal/forward/raw
// └─► hijack + yamux stream → fake tunnel client
// └─► fake local server 回 chunked response
//
// 這是 B4 任務最關鍵的里程碑:證明整條雲端版架構能跑。
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/hashicorp/yamux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/oidctest"
"visiona-backend/internal/relay"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
"visiona-backend/internal/wsconn"
)
// fixtureOIDCClientID / fixtureOIDCClientSecret 是測試用的 OIDC client 憑證。
// 與 fakeOIDC server 內 SetClientCredentials 對齊。
const (
fixtureOIDCClientID = "visiona-backend-fixture"
fixtureOIDCClientSecret = "fixture-test-secret"
fixtureSessionSecret = "fixture-session-signing-secret-32b!"
)
func init() {
// 避免 gin debug log 汙染測試輸出
gin.SetMode(gin.TestMode)
}
// lazyHandler 是 swap-able 的 http.Handler 包裝,讓 fixture 可以先 Start 拿 URL
// 再把真正的 router 裝進來解循環依賴storage.baseURL 需要 apiServer.URL
// 而 storage 又是 router 的依賴)。
//
// 並發安全Set 與 ServeHTTP 都透過 sync/atomic.Value 同步。
type lazyHandler struct {
v atomic.Value // http.Handler
}
func (l *lazyHandler) Set(h http.Handler) {
l.v.Store(h)
}
func (l *lazyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
v := l.v.Load()
if v == nil {
http.Error(w, "router not initialized", http.StatusServiceUnavailable)
return
}
v.(http.Handler).ServeHTTP(w, r)
}
// testFixture 把 integration test 需要的所有 server 集中管理,方便 setup/teardown。
type testFixture struct {
apiServer *httptest.Server
internalSrv *httptest.Server
tunnelSrv *httptest.Server
localBackend *httptest.Server
store *session.InMemoryStore
forwarder *session.Forwarder
// fakeOIDC 是 OB5 起新增 — 所有 fixture 內建一個 fake OIDC server
// 讓需要走 AuthMiddleware 的 test 可以用 AuthenticatedClient 一鍵完成登入流程。
fakeOIDC *oidctest.Server
// pairingStore / sessionMgr 暴露給少數需要直接操作 store 的 test。
pairingStore *auth.InMemoryPairingStore
sessionMgr *usersession.Manager
// router 暴露 *gin.Engine 給需要列出所有 route 的 test
// (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression
router *gin.Engine
}
func (f *testFixture) Close() {
if f.apiServer != nil {
f.apiServer.Close()
}
if f.internalSrv != nil {
f.internalSrv.Close()
}
if f.tunnelSrv != nil {
f.tunnelSrv.Close()
}
if f.localBackend != nil {
f.localBackend.Close()
}
// fakeOIDC 用 t.Cleanup 自動關NewServer 內已註冊),這裡不需手動。
}
// setupFixture 啟動完整的 5 段架構:
// - localBackend扮演 local-tool127.0.0.1:3721
// - tunnel serverremote-proxy 對 local agent 的 WS port
// - internal serverremote-proxy 對 api-server 的 internal HTTP port
// - api-server給前端用的 REST/Gin
//
// 注意fake tunnel client 沒在這裡 spawn因為各 test case 對 token 的需求不同。
func setupFixture(t *testing.T, localHandler http.Handler) *testFixture {
return setupFixtureWithMaxUpload(t, localHandler, 0) // 0 = 不限
}
// setupFixtureWithMaxUpload 同 setupFixture 但可設定 MaxUploadSizeMB
// B5 的 model-too-large test 需要這個。
//
// 另一個微妙的差別storage 的 baseURL 設為 apiServer.URL + "/storage"
// 這樣 PUT /storage/{key} 的 presigned URL 能被同一個 apiServer 處理,
// b5_integration_test.go 的上傳流程才能端對端跑通。
//
// OB5 起內建 fake OIDC server + OIDC wiringOIDC 是唯一認證路徑)。
// 走 AuthMiddleware 的 test 應透過 fixture.AuthenticatedClient 完成登入。
func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploadMB int) *testFixture {
t.Helper()
// 1. fake local-tool (127.0.0.1:3721 模擬)
localBackend := httptest.NewServer(localHandler)
// 2. remote-proxy
store := session.NewInMemoryStore()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
relaySrv := relay.NewServer(store, logger, relay.Options{KeepAliveInterval: 500 * time.Millisecond})
internalSrv := relay.NewInternalServer(store, logger)
// tunnel mux (面向 fake local agent)
tunnelMux := http.NewServeMux()
tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect)
tunnelTS := httptest.NewServer(tunnelMux)
// internal mux (面向 api-server)
internalMux := http.NewServeMux()
internalSrv.Routes(internalMux)
internalTS := httptest.NewServer(internalMux)
// 3. api-server — 透過 ProxyClient/Forwarder 指向上面的 internalTS
proxyClient := session.NewHTTPProxyClient(internalTS.URL, logger)
forwarder := session.NewForwarder(internalTS.URL, logger)
sessionStore := session.NewProxyClientStore(proxyClient, forwarder)
// 需要先知道 api-server URL 才能建 storagepresigned URL 的 baseURL
// 但 storage 又是 router 的依賴。解法:用 lazyHandler — 一個可以被 swap 的
// http.Handler讓我們先 Start server 拿 URL再把真正的 router 裝進去。
storeDir := t.TempDir()
lazy := &lazyHandler{}
apiTS := httptest.NewServer(lazy)
storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret")
require.NoError(t, err)
// 4. fake OIDC + OIDC clientOB5唯一認證路徑
fakeOIDC := oidctest.NewServer(t,
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
)
callbackURL := apiTS.URL + "/api/auth/callback"
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second)
oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{
IssuerURL: fakeOIDC.URL,
ClientID: fakeOIDC.ClientID,
ClientSecret: fakeOIDC.ClientSecret,
RedirectURL: callbackURL,
})
oidcCancel()
require.NoError(t, err, "fixture: OIDC provider init failed")
sessionMgr := usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{
Name: "visiona_session",
Path: "/",
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: []byte(fixtureSessionSecret),
})
pairingStore := auth.NewInMemoryPairingStore()
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pairingStore,
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: device.NewInMemoryRepository(),
ModelRepo: model.NewInMemoryRepository(),
Storage: storeStore,
Converter: converter.NewStubClient(),
// Phase 0.7 security fix C1StaticUserID 已從 Deps 移除(見 internal/api/api.go:77-80 註解)。
// 整合測試走 fixture.AuthenticatedClient 完整 OIDC login flow 取 cookie不再走 fallback 捷徑。
MaxUploadSizeMB: maxUploadMB,
RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL
// OIDC wiringOB5
OIDCProvider: oidcProvider,
SessionManager: sessionMgr,
OIDCPostLoginURL: apiTS.URL, // 把 frontend redirect 收回自己,方便測試 follow up
})
lazy.Set(router)
return &testFixture{
apiServer: apiTS,
internalSrv: internalTS,
tunnelSrv: tunnelTS,
localBackend: localBackend,
store: store,
forwarder: forwarder,
fakeOIDC: fakeOIDC,
pairingStore: pairingStore,
sessionMgr: sessionMgr,
router: router,
}
}
// startFakeTunnelClient 模擬 local agent
// - 對 tunnel server 開 WebSocket 上 yamux client
// - 對每條 stream 用 http.ReadRequest 解出 request → 真 TCP 轉發到 localAddr
func startFakeTunnelClient(t *testing.T, tunnelHTTPURL, token, localAddr string) (stop func()) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(tunnelHTTPURL, "http") + "/tunnel/connect"
u, err := url.Parse(wsURL)
require.NoError(t, err)
q := u.Query()
q.Set("token", token)
u.RawQuery = q.Encode()
rawWS, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
require.NoError(t, err, "fake tunnel client failed to dial")
netConn := wsconn.New(rawWS)
ym, err := yamux.Client(netConn, yamux.DefaultConfig())
require.NoError(t, err)
done := make(chan struct{})
go func() {
defer close(done)
for {
stream, aerr := ym.Accept()
if aerr != nil {
return
}
go func(s net.Conn) {
defer s.Close()
req, rerr := http.ReadRequest(bufio.NewReader(s))
if rerr != nil {
return
}
// 改寫 reqscheme/host 指向 fake localBackend重設 RequestURI
req.URL.Scheme = "http"
req.URL.Host = localAddr
req.Host = localAddr
req.RequestURI = ""
resp, rerr := http.DefaultTransport.RoundTrip(req)
if rerr != nil {
_ = (&http.Response{
StatusCode: http.StatusBadGateway,
ProtoMajor: 1, ProtoMinor: 1,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(nil)),
}).Write(s)
return
}
defer resp.Body.Close()
_ = resp.Write(s)
}(stream)
}
}()
return func() {
_ = ym.Close()
_ = rawWS.Close()
<-done
}
}
// ----------------------------------------------------------------------
// Test cases
// ----------------------------------------------------------------------
// TestIntegration_HealthEndpoint 驗證 /healthz不需 auth+ /api/system/infoOIDC都能 200。
func TestIntegration_HealthEndpoint(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
// /healthz不走 AuthMiddleware
resp, err := http.Get(f.apiServer.URL + "/healthz")
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "path=/healthz")
// /api/system/info走 AuthMiddleware → 需要 cookie
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
resp2, err := client.Get(f.apiServer.URL + "/api/system/info")
require.NoError(t, err)
resp2.Body.Close()
assert.Equal(t, http.StatusOK, resp2.StatusCode, "path=/api/system/info")
}
// TestIntegration_SystemHealth_NoTunnel 驗證沒 tunnel 時,
// /api/system/health 回 connected=false且整條 ProxyClient → remote-proxy 路徑通)。
func TestIntegration_SystemHealth_NoTunnel(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
resp, err := client.Get(f.apiServer.URL + "/api/system/health")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, true, body["success"])
data := body["data"].(map[string]any)
assert.Equal(t, "ok", data["api_server"])
assert.Equal(t, false, data["tunnel_connected"])
}
// TestIntegration_SystemHealth_WithTunnel 驗證有 tunnel 時,
// /api/system/health 回 connected=true證明整條 api-server → ProxyClient
// → remote-proxy → InMemoryStore 鏈路正常)。
func TestIntegration_SystemHealth_WithTunnel(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
const token = "vAc_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
stop := startFakeTunnelClient(t, f.tunnelSrv.URL, token,
strings.TrimPrefix(f.localBackend.URL, "http://"))
defer stop()
require.Eventually(t, func() bool {
ok, _ := f.store.Exists(context.Background(), token)
return ok
}, 2*time.Second, 20*time.Millisecond)
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
resp, err := client.Get(f.apiServer.URL + "/api/system/health")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
data := body["data"].(map[string]any)
assert.Equal(t, true, data["tunnel_connected"], "預期 tunnel_connected=true實際 body=%v", body)
assert.EqualValues(t, 1, data["agent_session_count"])
}
// TestIntegration_PairingTokenCreate 驗證 POST /api/pairing/token 能成功建立 token
// 且回傳的 token 之後可以拿來連 tunnel端到端覆蓋整條 pairing → tunnel 流程)。
func TestIntegration_PairingTokenCreate(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
// 1. POST /api/pairing/token
resp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
data := body["data"].(map[string]any)
tok, _ := data["token"].(string)
require.True(t, auth.IsValidPairingToken(tok), "token 應為合法 pairing 格式:%s", tok)
// 2. 拿這個 token 連 tunnel — 應該成功
stop := startFakeTunnelClient(t, f.tunnelSrv.URL, tok,
strings.TrimPrefix(f.localBackend.URL, "http://"))
defer stop()
require.Eventually(t, func() bool {
ok, _ := f.store.Exists(context.Background(), tok)
return ok
}, 2*time.Second, 20*time.Millisecond, "新 pairing token 應能成功註冊 tunnel session")
}
// TestIntegration_Forwarder_EndToEnd 是 B4 的核心驗證:
//
// 走完一整條
//
// api-server (Forwarder.OpenStream)
// └─► raw TCP → remote-proxy: POST /internal/forward/raw
// └─► hijack + OpenStream 走 yamux
// └─► fake tunnel client 收到 stream
// └─► 真 TCP forward 到 fake localBackend
// └─► local 回 200 + JSON body
//
// 這個測試證明 B4 完整 forwarder 鏈路可運作B5 接 handler 時可以放心呼叫
// Forwarder.ForwardHTTP 而不必再驗證底層。
func TestIntegration_Forwarder_EndToEnd(t *testing.T) {
const expectedRoute = "/api/devices"
const expectedHeader = "from-api-server"
// fake localBackend 收到 / 後回一段 JSON
localHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Echo-Path", r.URL.Path)
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"method": r.Method,
"path": r.URL.Path,
"echo_header": r.Header.Get("X-From-Api"),
})
})
f := setupFixture(t, localHandler)
defer f.Close()
const token = "vAc_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
stop := startFakeTunnelClient(t, f.tunnelSrv.URL, token,
strings.TrimPrefix(f.localBackend.URL, "http://"))
defer stop()
require.Eventually(t, func() bool {
ok, _ := f.store.Exists(context.Background(), token)
return ok
}, 2*time.Second, 20*time.Millisecond)
// 用 forwarder 直接 ForwardHTTP模擬 B5 的 handler 會做的事)
req, err := http.NewRequest(http.MethodGet, expectedRoute, nil)
require.NoError(t, err)
req.Header.Set("X-From-Api", expectedHeader)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := f.forwarder.ForwardHTTP(ctx, token, req)
require.NoError(t, err, "Forwarder.ForwardHTTP 應該成功")
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, expectedRoute, resp.Header.Get("X-Echo-Path"))
var payload map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
assert.Equal(t, "GET", payload["method"])
assert.Equal(t, expectedRoute, payload["path"])
assert.Equal(t, expectedHeader, payload["echo_header"])
}
// TestIntegration_Forwarder_TunnelDisconnected 驗證當 token 不存在時,
// Forwarder.OpenStream 回 ErrSessionNotFound讓 caller handler 可以轉 502
func TestIntegration_Forwarder_TunnelDisconnected(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := f.forwarder.OpenStream(ctx, "vAc_doesnotexistdoesnotexistdoesno")
require.Error(t, err)
assert.ErrorIs(t, err, session.ErrSessionNotFound,
"預期 ErrSessionNotFound實際 err=%v", err)
}
// TestIntegration_Stub_NotImplemented 驗證 B5/B7 仍未補的 endpoint 確實回 501。
//
// B5 後 /api/devices 已改為實際 handler回空陣列所以改驗 /api/converter/jobs —
// 那個是 Phase 1 才會做、目前仍為 stub。
func TestIntegration_Stub_NotImplemented(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
resp, err := client.Get(f.apiServer.URL + "/api/converter/jobs")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotImplemented, resp.StatusCode)
}
// TestIntegration_CORS_Preflight 驗證 CORS preflight 對 localhost:3000 放行。
func TestIntegration_CORS_Preflight(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
req, _ := http.NewRequest(http.MethodOptions, f.apiServer.URL+"/api/system/health", nil)
req.Header.Set("Origin", "http://localhost:3000")
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Contains(t, resp.Header.Get("Access-Control-Allow-Origin"), "localhost:3000",
"預期 Allow-Origin 帶 localhost:3000實際 header: %v", resp.Header)
}