從 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>
139 lines
5.8 KiB
Go
139 lines
5.8 KiB
Go
// pairing_exchange_test.go — AB11: POST /api/pairing/exchange 的 end-to-end integration test。
|
||
//
|
||
// 覆蓋情境:
|
||
// - 產 Pairing Token(POST /api/pairing/token,走 AuthMiddleware → 需 OIDC cookie)
|
||
// - 拿 Pairing Token 換 Session Token(POST /api/pairing/exchange,不走 AuthMiddleware)
|
||
// - 拿 Session Token 連 tunnel(remote-proxy 只做格式驗證 → 應能接受 vAs_)
|
||
// - 驗證同一個 Pairing Token 無法重複兌換
|
||
//
|
||
// 雛形取捨:
|
||
// - remote-proxy 目前**不會**回頭驗證 Session Token 是否出自 api-server(選項 A)。
|
||
// 故本測試沒有驗證「跨進程 session store 同步」— 這留給 Phase 1 實作。
|
||
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/api"
|
||
"visiona-backend/internal/auth"
|
||
)
|
||
|
||
// TestAB11_PairingExchange_EndToEnd 跑完整個雛形 exchange → tunnel-connect 流程。
|
||
func TestAB11_PairingExchange_EndToEnd(t *testing.T) {
|
||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}))
|
||
defer f.Close()
|
||
|
||
client := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
|
||
|
||
// 1. POST /api/pairing/token → 拿一個 Pairing Token(走 AuthMiddleware,OIDC cookie 放行)
|
||
tokResp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil)
|
||
require.NoError(t, err)
|
||
defer tokResp.Body.Close()
|
||
require.Equal(t, http.StatusOK, tokResp.StatusCode)
|
||
|
||
var tokBody map[string]any
|
||
require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody))
|
||
pairingTok := tokBody["data"].(map[string]any)["token"].(string)
|
||
require.True(t, auth.IsValidPairingToken(pairingTok), "token 格式應合法:%s", pairingTok)
|
||
|
||
// 2. POST /api/pairing/exchange → 換 Session Token(不走 AuthMiddleware)
|
||
reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok})
|
||
exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange",
|
||
"application/json", bytes.NewReader(reqBody))
|
||
require.NoError(t, err)
|
||
defer exchResp.Body.Close()
|
||
bodyBytes, _ := io.ReadAll(exchResp.Body)
|
||
require.Equal(t, http.StatusOK, exchResp.StatusCode, "body: %s", string(bodyBytes))
|
||
|
||
var exchBody map[string]any
|
||
require.NoError(t, json.Unmarshal(bodyBytes, &exchBody))
|
||
data := exchBody["data"].(map[string]any)
|
||
sessionTok := data["session_token"].(string)
|
||
require.True(t, auth.IsValidSessionToken(sessionTok), "session_token 格式應合法:%s", sessionTok)
|
||
assert.NotEmpty(t, data["relay_url"])
|
||
assert.NotEmpty(t, data["account"])
|
||
assert.NotEmpty(t, data["expires_at"])
|
||
|
||
// account 應綁到 OIDC sub(OB5 升級的關鍵驗證 — 不再是 demo-user@...)
|
||
assert.Equal(t, "demo-user@visionA.local", data["account"],
|
||
"OB5 起 account 應 = OIDC sub + suffix;本 test 用 demo-user 當 sub")
|
||
|
||
// 3. 拿 Session Token 連 tunnel — remote-proxy 只做格式驗證,應該接受
|
||
stop := startFakeTunnelClient(t, f.tunnelSrv.URL, sessionTok, f.localBackend.URL[len("http://"):])
|
||
defer stop()
|
||
|
||
// 等 session 建立(session 進 store 需要非同步 handshake)
|
||
require.Eventually(t, func() bool {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||
defer cancel()
|
||
summaries, err := f.store.List(ctx)
|
||
return err == nil && len(summaries) == 1
|
||
}, 2*time.Second, 50*time.Millisecond, "tunnel session 應該建立")
|
||
|
||
// 4. 同一 pairing token 再換一次 → 應該 401 PAIRING_TOKEN_USED
|
||
exchResp2, err := http.Post(f.apiServer.URL+"/api/pairing/exchange",
|
||
"application/json", bytes.NewReader(reqBody))
|
||
require.NoError(t, err)
|
||
defer exchResp2.Body.Close()
|
||
assert.Equal(t, http.StatusUnauthorized, exchResp2.StatusCode)
|
||
body2, _ := io.ReadAll(exchResp2.Body)
|
||
assert.Contains(t, string(body2), "PAIRING_TOKEN_USED")
|
||
}
|
||
|
||
// TestAB11_PairingExchange_Unauth 驗證 /api/pairing/exchange 本身不受 AuthMiddleware 管控。
|
||
//
|
||
// OB5 起 AuthMiddleware 已是 OIDC(cookie),exchange endpoint 必須仍然能用「沒登入的
|
||
// 純 HTTP client」打通 — 因為 agent 端就是 unauthenticated 來換 session token 的。
|
||
func TestAB11_PairingExchange_Unauth(t *testing.T) {
|
||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||
defer f.Close()
|
||
|
||
// 先用 OIDC client 拿一個 pairing token(authenticated)
|
||
authClient := f.AuthenticatedClient(t, "demo-user", "demo@visiona.local")
|
||
tokResp, err := authClient.Post(f.apiServer.URL+"/api/pairing/token", "", nil)
|
||
require.NoError(t, err)
|
||
defer tokResp.Body.Close()
|
||
var tokBody map[string]any
|
||
require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody))
|
||
pairingTok := tokBody["data"].(map[string]any)["token"].(string)
|
||
|
||
// 送 exchange,刻意用「沒任何 cookie / Auth header」的 default client — 應該還是 200 OK
|
||
reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok})
|
||
req, _ := http.NewRequest(http.MethodPost, f.apiServer.URL+"/api/pairing/exchange",
|
||
bytes.NewReader(reqBody))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
}
|
||
|
||
// TestAB11_PairingExchange_InvalidFormat 驗證不合法格式的 token 回 401 INVALID_PAIRING_TOKEN。
|
||
func TestAB11_PairingExchange_InvalidFormat(t *testing.T) {
|
||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||
defer f.Close()
|
||
|
||
resp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange",
|
||
"application/json",
|
||
strings.NewReader(`{"pairing_token":"not-valid"}`))
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
body, _ := io.ReadAll(resp.Body)
|
||
assert.Contains(t, string(body), "INVALID_PAIRING_TOKEN")
|
||
}
|