visionA/visionA-backend/cmd/api-server/integration_test.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00

543 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/user"
"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
// userStore / deviceRepo 暴露給 DB-on FK 收尾的 test
// - userStore驗證 OIDC callback 後 user 被 provision問題 #1
// - deviceRepo驗證 pairing exchange 後自建 device問題 #2
userStore *user.InMemoryStore
deviceRepo *device.InMemoryRepository
// 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()
sessionTokenStore := auth.NewInMemorySessionTokenStore()
deviceRepo := device.NewInMemoryRepository()
userStore := user.NewInMemoryStore()
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pairingStore,
SessionTokenStore: sessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: deviceRepo,
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
// DB-on FK 收尾in-memory user store問題 #1+ in-memory pairing exchanger問題 #2
UserStore: userStore,
PairingExchanger: api.NewInMemoryPairingExchanger(deviceRepo, sessionTokenStore),
// 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,
userStore: userStore,
deviceRepo: deviceRepo,
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)
}