從 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>
324 lines
12 KiB
Go
324 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/session"
|
||
)
|
||
|
||
// TestPairingCreateToken_OK 驗證能成功建 pairing token,且回傳格式合法。
|
||
func TestPairingCreateToken_OK(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
// Phase 0.7 security fix C1:移除 Deps.StaticUserID,改由 injectStaticUserContext 顯式注入。
|
||
registerPairingRoutes(g, Deps{
|
||
Logger: nil,
|
||
PairingStore: auth.NewInMemoryPairingStore(),
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/pairing/token", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
require.True(t, body.Success)
|
||
|
||
data := body.Data.(map[string]any)
|
||
tok, _ := data["token"].(string)
|
||
assert.True(t, strings.HasPrefix(tok, "vAc_"), "token 應為 pairing 格式:%s", tok)
|
||
assert.True(t, auth.IsValidPairingToken(tok), "token 應通過格式驗證")
|
||
assert.NotEmpty(t, data["expires_at"])
|
||
}
|
||
|
||
// TestPairingCreateToken_NoStore 驗證沒注入 PairingStore 時回 501。
|
||
func TestPairingCreateToken_NoStore(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
g := r.Group("/api")
|
||
registerPairingRoutes(g, Deps{Logger: nil})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/pairing/token", nil))
|
||
assert.Equal(t, 501, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeNotImplemented)
|
||
}
|
||
|
||
// TestPairingStatus_NoSession 驗證沒 session 時回 connected=false。
|
||
func TestPairingStatus_NoSession(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
g := r.Group("/api")
|
||
registerPairingRoutes(g, Deps{
|
||
SessionStore: &fakeSessionStore{},
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/pairing/status", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
data := body.Data.(map[string]any)
|
||
assert.Equal(t, false, data["connected"])
|
||
}
|
||
|
||
// ==========================================================================
|
||
// AB11:POST /api/pairing/exchange 測試
|
||
// ==========================================================================
|
||
|
||
// setupExchangeRouter 建立一個只掛 exchange endpoint 的 minimal router。
|
||
//
|
||
// 重點:exchange **不走** AuthMiddleware,故不掛 AuthMiddleware。
|
||
// 這也反映了 production 的 NewRouter 實際行為(registerPairingPublicRoutes 在
|
||
// engine 層註冊,而不是 apiGroup)。
|
||
func setupExchangeRouter(t *testing.T, deps Deps) *gin.Engine {
|
||
t.Helper()
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
registerPairingPublicRoutes(r, deps)
|
||
return r
|
||
}
|
||
|
||
// issuePairingToken 建一個合法 pairing token 供 exchange 測試用。
|
||
func issuePairingToken(t *testing.T, store auth.PairingStore, userID string, ttl time.Duration) string {
|
||
t.Helper()
|
||
plain, _, err := store.Create(context.Background(), userID, ttl)
|
||
require.NoError(t, err)
|
||
return plain
|
||
}
|
||
|
||
// TestPairingExchange_OK 驗證 happy path:拿合法 pairing token 換到 session token。
|
||
func TestPairingExchange_OK(t *testing.T) {
|
||
pairings := auth.NewInMemoryPairingStore()
|
||
sessions := auth.NewInMemorySessionTokenStore()
|
||
pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute)
|
||
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: pairings,
|
||
SessionTokenStore: sessions,
|
||
RelayPublicURL: "wss://relay.test.local",
|
||
})
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
|
||
var resp SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||
require.True(t, resp.Success)
|
||
|
||
data := resp.Data.(map[string]any)
|
||
sessTok, _ := data["session_token"].(string)
|
||
assert.True(t, auth.IsValidSessionToken(sessTok), "session_token 應為合法 vAs_ 格式:%s", sessTok)
|
||
assert.Equal(t, "wss://relay.test.local", data["relay_url"])
|
||
assert.Equal(t, "demo-user@visionA.local", data["account"])
|
||
assert.NotEmpty(t, data["expires_at"])
|
||
|
||
// Session token 應能從 store 查到
|
||
_, err := sessions.Get(context.Background(), sessTok)
|
||
assert.NoError(t, err)
|
||
|
||
// Pairing token 應被標為 used;再 exchange 一次應該失敗
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req2.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w2, req2)
|
||
assert.Equal(t, http.StatusUnauthorized, w2.Code)
|
||
assert.Contains(t, w2.Body.String(), ErrCodePairingTokenUsed)
|
||
}
|
||
|
||
// TestPairingExchange_InvalidFormat 驗證格式錯的 token 回 401 INVALID_PAIRING_TOKEN。
|
||
func TestPairingExchange_InvalidFormat(t *testing.T) {
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: auth.NewInMemoryPairingStore(),
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
})
|
||
|
||
// 格式錯(缺前綴)
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: "not-a-real-token"})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeInvalidPairingToken)
|
||
}
|
||
|
||
// TestPairingExchange_MissingField 驗證 body 沒 pairing_token 回 400 VALIDATION_FAILED。
|
||
func TestPairingExchange_MissingField(t *testing.T) {
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: auth.NewInMemoryPairingStore(),
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange",
|
||
strings.NewReader(`{}`))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
|
||
}
|
||
|
||
// TestPairingExchange_Unknown 驗證合法格式但 store 找不到的 token 回 401 INVALID_PAIRING_TOKEN。
|
||
func TestPairingExchange_Unknown(t *testing.T) {
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: auth.NewInMemoryPairingStore(),
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
})
|
||
|
||
// 格式合法但 store 沒存過
|
||
unknown, err := auth.GeneratePairingToken()
|
||
require.NoError(t, err)
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: unknown})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeInvalidPairingToken)
|
||
}
|
||
|
||
// TestPairingExchange_Expired 驗證過期 token 回 401 PAIRING_TOKEN_EXPIRED。
|
||
func TestPairingExchange_Expired(t *testing.T) {
|
||
pairings := auth.NewInMemoryPairingStore()
|
||
// TTL 1ns → 幾乎立刻過期
|
||
pairingTok := issuePairingToken(t, pairings, "demo-user", 1*time.Nanosecond)
|
||
time.Sleep(5 * time.Millisecond)
|
||
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: pairings,
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
})
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodePairingTokenExpired)
|
||
}
|
||
|
||
// TestPairingExchange_Revoked 驗證撤銷 token 回 401 PAIRING_TOKEN_REVOKED。
|
||
func TestPairingExchange_Revoked(t *testing.T) {
|
||
pairings := auth.NewInMemoryPairingStore()
|
||
pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute)
|
||
require.NoError(t, pairings.Revoke(context.Background(), pairingTok))
|
||
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: pairings,
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
})
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodePairingTokenRevoked)
|
||
}
|
||
|
||
// TestPairingExchange_NoStore 驗證 SessionTokenStore / PairingStore 缺失時回 501。
|
||
func TestPairingExchange_NoStore(t *testing.T) {
|
||
r := setupExchangeRouter(t, Deps{}) // 兩個 store 都 nil
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: "vAc_" + strings.Repeat("0", 32)})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, 501, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeNotImplemented)
|
||
}
|
||
|
||
// TestPairingExchange_DefaultRelayURL 驗證沒設 RelayPublicURL 時會 fallback 到 placeholder。
|
||
func TestPairingExchange_DefaultRelayURL(t *testing.T) {
|
||
pairings := auth.NewInMemoryPairingStore()
|
||
sessions := auth.NewInMemorySessionTokenStore()
|
||
pairingTok := issuePairingToken(t, pairings, "demo-user", 15*time.Minute)
|
||
|
||
r := setupExchangeRouter(t, Deps{
|
||
PairingStore: pairings,
|
||
SessionTokenStore: sessions,
|
||
// RelayPublicURL 刻意留空
|
||
})
|
||
|
||
body, _ := json.Marshal(PairingExchangeRequest{PairingToken: pairingTok})
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/pairing/exchange", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||
|
||
var resp SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||
data := resp.Data.(map[string]any)
|
||
assert.Equal(t, defaultRelayPublicURL, data["relay_url"])
|
||
}
|
||
|
||
// TestPairingStatus_WithSession 驗證有 session 時回 connected=true + 對應欄位。
|
||
//
|
||
// Phase 0.7 security fix M2:pairingStatusHandler 已改為 strict equality,
|
||
// 必須顯式注入 UserContext 才能拿到匹配 session(不再走「空 UserID 視為 match」捷徑)。
|
||
func TestPairingStatus_WithSession(t *testing.T) {
|
||
now := time.Now().UTC().Truncate(time.Second)
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerPairingRoutes(g, Deps{
|
||
SessionStore: &fakeSessionStore{
|
||
sessions: []*session.Summary{
|
||
{
|
||
Token: "vAc_a",
|
||
UserID: "demo-user",
|
||
DeviceID: "dev-1",
|
||
ConnectedAt: now.Add(-1 * time.Hour),
|
||
LastHeartbeat: now,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/pairing/status", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var body SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||
data := body.Data.(map[string]any)
|
||
assert.Equal(t, true, data["connected"])
|
||
assert.Equal(t, "dev-1", data["device_id"])
|
||
assert.NotEmpty(t, data["connected_at"])
|
||
assert.NotEmpty(t, data["last_seen_at"])
|
||
}
|