// 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 // └─► InMemoryStore(fake 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-tool(127.0.0.1:3721) // - tunnel server:remote-proxy 對 local agent 的 WS port // - internal server:remote-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 wiring(OIDC 是唯一認證路徑)。 // 走 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 才能建 storage(presigned 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 client(OB5:唯一認證路徑) 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 C1:StaticUserID 已從 Deps 移除(見 internal/api/api.go:77-80 註解)。 // 整合測試走 fixture.AuthenticatedClient 完整 OIDC login flow 取 cookie,不再走 fallback 捷徑。 MaxUploadSizeMB: maxUploadMB, RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL // OIDC wiring(OB5) 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 } // 改寫 req:scheme/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/info(OIDC)都能 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) }