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>
543 lines
19 KiB
Go
543 lines
19 KiB
Go
// 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/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-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()
|
||
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 C1:StaticUserID 已從 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 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,
|
||
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
|
||
}
|
||
// 改寫 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)
|
||
}
|