從 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>
132 lines
4.4 KiB
Go
132 lines
4.4 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
|
||
"visiona-backend/internal/usersession"
|
||
)
|
||
|
||
func init() {
|
||
gin.SetMode(gin.TestMode)
|
||
}
|
||
|
||
// newTestSessionManager 是給 middleware_test 用的最小 SessionManager fixture。
|
||
//
|
||
// OB5 起 AuthMiddleware 必須有 SessionManager — Static fallback 已拔除。
|
||
func newTestSessionManager() *usersession.Manager {
|
||
return usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{
|
||
Name: "visiona_session",
|
||
Path: "/",
|
||
HTTPOnly: true,
|
||
SameSite: http.SameSiteLaxMode,
|
||
MaxAge: 86400,
|
||
SigningKey: []byte("middleware-test-signing-key-32b-aa"),
|
||
})
|
||
}
|
||
|
||
// TestRequestIDMiddleware_GeneratesNew 驗證沒帶 header 時會產生新的 request id。
|
||
func TestRequestIDMiddleware_GeneratesNew(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.GET("/", func(c *gin.Context) {
|
||
c.String(http.StatusOK, RequestIDFrom(c))
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusOK, w.Code)
|
||
body := w.Body.String()
|
||
assert.NotEmpty(t, body, "request id 應寫入 context")
|
||
assert.Equal(t, body, w.Header().Get("X-Request-ID"), "header 應與 context 一致")
|
||
}
|
||
|
||
// TestRequestIDMiddleware_PreservesIncoming 驗證帶 header 時會沿用。
|
||
func TestRequestIDMiddleware_PreservesIncoming(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.GET("/", func(c *gin.Context) {
|
||
c.String(http.StatusOK, RequestIDFrom(c))
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.Header.Set("X-Request-ID", "upstream-123")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, "upstream-123", w.Body.String())
|
||
assert.Equal(t, "upstream-123", w.Header().Get("X-Request-ID"))
|
||
}
|
||
|
||
// TestAuthMiddleware_NoCookie_Rejects 驗證沒 cookie 時 → 401 + "no_session"。
|
||
func TestAuthMiddleware_NoCookie_Rejects(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(AuthMiddleware(Deps{SessionManager: newTestSessionManager()}))
|
||
r.GET("/", func(c *gin.Context) {
|
||
c.String(http.StatusOK, "should not reach")
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||
assert.Contains(t, w.Body.String(), "no_session")
|
||
}
|
||
|
||
// 註:AuthMiddleware 的「pending session → 401」與「authenticated session → 通過」的
|
||
// 完整測試在 oidc_auth_test.go(TestOIDCMiddleware_Allows_AuthenticatedSession /
|
||
// TestOIDCMiddleware_Rejects_PendingSession),因為需要走完整 login flow 才能模擬。
|
||
|
||
// TestRecoveryMiddleware_CatchesPanic 驗證 handler panic 會被攔成 500 + INTERNAL_ERROR。
|
||
func TestRecoveryMiddleware_CatchesPanic(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
r.Use(RecoveryMiddleware(nil))
|
||
r.GET("/", func(c *gin.Context) {
|
||
panic("boom")
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeInternalError)
|
||
}
|
||
|
||
// TestStripBearerPrefix 驗證 Bearer token prefix 處理。
|
||
func TestStripBearerPrefix(t *testing.T) {
|
||
assert.Equal(t, "abc123", StripBearerPrefix("Bearer abc123"))
|
||
assert.Equal(t, "abc123", StripBearerPrefix("abc123"))
|
||
assert.Equal(t, "", StripBearerPrefix(""))
|
||
}
|
||
|
||
// TestCORSMiddleware_AllowsConfiguredOrigin 驗證只放行白名單 Origin。
|
||
func TestCORSMiddleware_AllowsConfiguredOrigin(t *testing.T) {
|
||
r := gin.New()
|
||
r.Use(CORSMiddleware([]string{"http://localhost:3000"}))
|
||
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||
|
||
// Allowed origin
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||
req.Header.Set("Origin", "http://localhost:3000")
|
||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||
r.ServeHTTP(w, req)
|
||
assert.True(t, strings.Contains(w.Header().Get("Access-Control-Allow-Origin"), "localhost:3000"),
|
||
"預期 Allow-Origin 包含 localhost:3000,實際 header: %v", w.Header())
|
||
|
||
// Disallowed origin
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||
req2.Header.Set("Origin", "http://evil.example")
|
||
req2.Header.Set("Access-Control-Request-Method", "GET")
|
||
r.ServeHTTP(w2, req2)
|
||
assert.NotContains(t, w2.Header().Get("Access-Control-Allow-Origin"), "evil.example")
|
||
}
|