從 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>
527 lines
19 KiB
Go
527 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/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)
|
||
}
|