visionA/visionA-backend/internal/api/oidc_auth_test.go
jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過,
讓登入/換帳號可用;另補真轉檔服務的整合 e2e。

OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過):
- email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發
  sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local
  placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫
- prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關)
- logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用
  隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。
  config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地)

真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集):
- real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result)
- real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS)

交接檔(給對應團隊根治):
- mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory)
- converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth

全程 Reviewer 審查 + 對 stage 真環境驗證。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:18:59 +08:00

801 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// oidc_auth_test.go — OIDC handler 與 OIDC-mode middleware 的 unit test。
//
// 設計策略:用 mockOIDCProvider 取代真實 IdP避免 IO、純 Go function call
// 這樣測試快且確定性高;真實 IdP 整合留給 OT1fake server+ OT2end-to-end
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/oidc"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession"
)
// ---- mockOIDCProvider ---------------------------------------------------
// mockOIDCProvider 實作 oidc.Provider回傳由 test 預先設定的固定值。
//
// 比 fake HTTP server 簡單很多:直接控制每個方法的回傳,可注入錯誤情境。
type mockOIDCProvider struct {
mu sync.Mutex
// AuthorizationURL 行為控制
authURLBase string // 預設 "https://idp.example/authorize"
// ExchangeCode 行為控制
exchangeFn func(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error)
// VerifyIDToken 行為控制
verifyFn func(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error)
// 記錄呼叫參數供 test assertion 用
gotCode string
gotVerifier string
gotIDToken string
gotNonce string
}
func (m *mockOIDCProvider) AuthorizationURL(state, nonce, codeChallenge string) string {
base := m.authURLBase
if base == "" {
base = "https://idp.example/authorize"
}
q := url.Values{}
q.Set("state", state)
q.Set("nonce", nonce)
q.Set("code_challenge", codeChallenge)
q.Set("code_challenge_method", "S256")
return base + "?" + q.Encode()
}
func (m *mockOIDCProvider) ExchangeCode(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error) {
m.mu.Lock()
m.gotCode = code
m.gotVerifier = verifier
m.mu.Unlock()
if m.exchangeFn != nil {
return m.exchangeFn(ctx, code, verifier)
}
return &oidc.TokenResponse{
AccessToken: "access-token-xyz",
IDToken: "id-token-xyz",
TokenType: "Bearer",
ExpiresIn: 3600,
}, nil
}
func (m *mockOIDCProvider) VerifyIDToken(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error) {
m.mu.Lock()
m.gotIDToken = raw
m.gotNonce = expectedNonce
m.mu.Unlock()
if m.verifyFn != nil {
return m.verifyFn(ctx, raw, expectedNonce)
}
return &oidc.Claims{
Subject: "user-123",
Email: "alice@example.com",
Name: "Alice",
Nonce: expectedNonce,
}, nil
}
// ---- helper: 建立啟用 OIDC 的測試 Deps + router ---------------------------
func newOIDCTestDeps(provider *mockOIDCProvider) Deps {
mgr := usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{
Name: "visiona_session",
Path: "/",
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: []byte("test-secret-32-byte-key-aaaaaaaaaaaa"),
})
return Deps{
OIDCProvider: provider,
SessionManager: mgr,
OIDCPostLoginURL: "http://localhost:3000",
}
}
// newOIDCRouter 建立完整 router含 public + apiGroup AuthMiddleware
func newOIDCRouter(deps Deps) *gin.Engine {
r := gin.New()
r.Use(RequestIDMiddleware())
// public OIDC routes必須在 AuthMiddleware 之外)
registerOIDCPublicRoutes(r, deps)
// /api 群組(含 AuthMiddleware + auth handlers
g := r.Group("/api")
g.Use(AuthMiddleware(deps))
registerAuthRoutes(g, deps)
return r
}
// ---- TESTS: oidcLoginHandler --------------------------------------------
// TestOIDCLogin_RedirectsToIdPWithProperParams 驗證 /api/auth/login 會 302 到 IdP
// 並設好 cookie + 在 session 中存好 PKCE state / nonce / verifier。
func TestOIDCLogin_RedirectsToIdPWithProperParams(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/dashboard", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code)
loc := w.Header().Get("Location")
require.NotEmpty(t, loc)
parsed, err := url.Parse(loc)
require.NoError(t, err)
// 應該有 state / nonce / code_challenge 三個 query
q := parsed.Query()
assert.NotEmpty(t, q.Get("state"))
assert.NotEmpty(t, q.Get("nonce"))
assert.NotEmpty(t, q.Get("code_challenge"))
assert.Equal(t, "S256", q.Get("code_challenge_method"))
// 應該有 Set-Cookie
cookies := w.Result().Cookies()
require.NotEmpty(t, cookies, "expected Set-Cookie")
var sessCookie *http.Cookie
for _, c := range cookies {
if c.Name == "visiona_session" {
sessCookie = c
break
}
}
require.NotNil(t, sessCookie, "expected visiona_session cookie")
assert.True(t, sessCookie.HttpOnly)
assert.Equal(t, http.SameSiteLaxMode, sessCookie.SameSite)
}
// TestOIDCLogin_SanitizesReturnTo 驗證 open redirect 防護。
func TestOIDCLogin_SanitizesReturnTo(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"empty", "", ""},
{"normal_path", "/dashboard", "/dashboard"},
{"path_with_query", "/devices?x=1", "/devices?x=1"},
{"absolute_url", "http://evil.example/", ""},
{"protocol_relative", "//evil.example", ""},
{"backslash_trick", "/\\evil.example", ""},
{"missing_leading_slash", "evil", ""},
{"scheme_in_path", "/foo://bar", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, sanitizeReturnTo(tc.raw))
})
}
}
// ---- TESTS: oidcCallbackHandler -----------------------------------------
// TestOIDCCallback_HappyPath 驗證 callback 完整流程跑通。
func TestOIDCCallback_HappyPath(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
// Step 1: 觸發 login拿到 state + cookie
loginW := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/devices", nil)
r.ServeHTTP(loginW, loginReq)
require.Equal(t, http.StatusFound, loginW.Code)
loc, _ := url.Parse(loginW.Header().Get("Location"))
state := loc.Query().Get("state")
require.NotEmpty(t, state)
// 提取 cookie
cookies := loginW.Result().Cookies()
require.NotEmpty(t, cookies)
// Step 2: 模擬 IdP 302 回 callback帶上 cookie + state
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=auth-code-xyz&state="+url.QueryEscape(state), nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
// 預期 302 回 frontend
require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String())
redirect := cbW.Header().Get("Location")
assert.Equal(t, "http://localhost:3000/devices", redirect)
// 驗 mock 收到正確的 code
assert.Equal(t, "auth-code-xyz", provider.gotCode)
assert.NotEmpty(t, provider.gotVerifier, "verifier should be passed to ExchangeCode")
}
// TestOIDCCallback_StateMismatch 驗證 state 不符回 400 並清 session。
func TestOIDCCallback_StateMismatch(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
// 先 login 拿 cookie
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
cookies := loginW.Result().Cookies()
// Callback 帶錯的 state
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=xyz&state=wrong-state", nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
assert.Equal(t, http.StatusBadRequest, cbW.Code)
assert.Contains(t, cbW.Body.String(), "state mismatch")
}
// TestOIDCCallback_NoCookie 驗證沒帶 cookie → 400。
func TestOIDCCallback_NoCookie(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=xyz&state=abc", nil)
r.ServeHTTP(cbW, cbReq)
assert.Equal(t, http.StatusBadRequest, cbW.Code)
assert.Contains(t, cbW.Body.String(), "no pending session")
}
// TestOIDCCallback_MissingCodeOrState 驗證 missing query 回 400。
func TestOIDCCallback_MissingCodeOrState(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
cbW := httptest.NewRecorder()
r.ServeHTTP(cbW, httptest.NewRequest(http.MethodGet, "/api/auth/callback", nil))
assert.Equal(t, http.StatusBadRequest, cbW.Code)
assert.Contains(t, cbW.Body.String(), "missing code or state")
}
// TestOIDCCallback_IdPError 驗證 IdP 回 error param → 400。
func TestOIDCCallback_IdPError(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
cbW := httptest.NewRecorder()
r.ServeHTTP(cbW, httptest.NewRequest(http.MethodGet,
"/api/auth/callback?error=access_denied&error_description=user_cancelled", nil))
assert.Equal(t, http.StatusBadRequest, cbW.Code)
assert.Contains(t, cbW.Body.String(), "access_denied")
}
// TestOIDCCallback_TokenExchangeInvalidGrant 驗證 invalid_grant → 400。
func TestOIDCCallback_TokenExchangeInvalidGrant(t *testing.T) {
provider := &mockOIDCProvider{
exchangeFn: func(ctx context.Context, code, verifier string) (*oidc.TokenResponse, error) {
return nil, oidc.ErrInvalidGrant
},
}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
state := mustExtractStateFromLoginRedirect(t, loginW)
cookies := loginW.Result().Cookies()
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=xyz&state="+url.QueryEscape(state), nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
assert.Equal(t, http.StatusBadRequest, cbW.Code)
assert.Contains(t, cbW.Body.String(), "token exchange failed")
}
// TestOIDCCallback_RotatesSessionID_PreventsFixation 驗證 Fix-A1session fixation 防護):
//
// 攻擊情境:攻擊者預先取得一個 pending session cookie自己跑 /api/auth/login
// 誘騙受害者使用此 cookie 走完 OIDC flow。
//
// 防護驗證:
// - callback 完成時必須 rotate cookie value瀏覽器收到的 Set-Cookie 與原 cookie value 不同)
// - 用「攻擊者持有的舊 cookie」訪 /api/auth/me 應該 401pending session 已不存在於 store
// - 用「callback 回傳的新 cookie」訪 /api/auth/me 應該 200已登入
func TestOIDCCallback_RotatesSessionID_PreventsFixation(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
// 模擬「攻擊者預先取得 pending cookie」
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
state := mustExtractStateFromLoginRedirect(t, loginW)
attackerCookies := loginW.Result().Cookies()
require.NotEmpty(t, attackerCookies)
attackerCookieValue := attackerCookies[0].Value
// 模擬「受害者用攻擊者的 cookie 走完 callback」
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil)
for _, c := range attackerCookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
require.Equal(t, http.StatusFound, cbW.Code, "callback should succeed; body=%s", cbW.Body.String())
// 驗證 1callback 必須寫一個新 cookie且 value 與舊 cookie 不同
newCookies := cbW.Result().Cookies()
require.NotEmpty(t, newCookies, "callback must write new Set-Cookie (rotation)")
var newSessCookie *http.Cookie
for _, c := range newCookies {
if c.Name == "visiona_session" {
newSessCookie = c
break
}
}
require.NotNil(t, newSessCookie, "expected visiona_session cookie after callback")
assert.NotEqual(t, attackerCookieValue, newSessCookie.Value,
"session fixation: cookie value MUST change after login (rotate session ID)")
// 驗證 2用攻擊者持有的舊 cookie 訪 /me → 401攻擊者拿不到 victim 帳號)
attackerMeW := httptest.NewRecorder()
attackerMeReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
for _, c := range attackerCookies {
attackerMeReq.AddCookie(c)
}
r.ServeHTTP(attackerMeW, attackerMeReq)
assert.Equal(t, http.StatusUnauthorized, attackerMeW.Code,
"attacker's old cookie must be rejected after rotation; body=%s", attackerMeW.Body.String())
// 驗證 3用受害者的新 cookie 訪 /me → 200合法
victimMeW := httptest.NewRecorder()
victimMeReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
for _, c := range newCookies {
victimMeReq.AddCookie(c)
}
r.ServeHTTP(victimMeW, victimMeReq)
assert.Equal(t, http.StatusOK, victimMeW.Code,
"victim's new cookie must be accepted; body=%s", victimMeW.Body.String())
}
// TestOIDCCallback_VerifyFails 驗證 id_token 驗證失敗 → 401。
func TestOIDCCallback_VerifyFails(t *testing.T) {
provider := &mockOIDCProvider{
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
return nil, oidc.ErrInvalidIDToken
},
}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
state := mustExtractStateFromLoginRedirect(t, loginW)
cookies := loginW.Result().Cookies()
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=xyz&state="+url.QueryEscape(state), nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
assert.Equal(t, http.StatusUnauthorized, cbW.Code)
assert.Contains(t, cbW.Body.String(), "id_token verification failed")
}
// ---- TESTS: email fallback (放寬 email 必填) -----------------------------
// recordingUserStore 是攔截 Upsert 的測試 user.Store記錄最後一次寫入的 User
// 供 assertion 驗證 provision 進 DB 的 email真 email vs fallback placeholder
type recordingUserStore struct {
mu sync.Mutex
lastUpsert *user.User
}
func (s *recordingUserStore) Upsert(ctx context.Context, in *user.User) error {
s.mu.Lock()
defer s.mu.Unlock()
cp := *in
s.lastUpsert = &cp
return nil
}
func (s *recordingUserStore) Get(ctx context.Context, id string) (*user.User, error) {
return nil, user.ErrNotFound
}
func (s *recordingUserStore) last() *user.User {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastUpsert
}
// runCallbackWithProvider 跑完整 login + callback回傳 callback 的 recorder。
func runCallbackWithProvider(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder {
t.Helper()
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
state := mustExtractStateFromLoginRedirect(t, loginW)
cookies := loginW.Result().Cookies()
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
return cbW
}
// TestOIDCCallback_EmailMissing_UsesFallback 驗證 email claim 為空時:
// - callback 仍 provision 成功302、非 500
// - 寫進 user store 的 email 是 "<sub>@noemail.visiona.local" placeholder含 sub、unique
func TestOIDCCallback_EmailMissing_UsesFallback(t *testing.T) {
provider := &mockOIDCProvider{
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
// 模擬 MC 現況:只有 sub / name沒有 email
return &oidc.Claims{Subject: "sub-uuid-1", Email: "", Name: "Bob", Nonce: nonce}, nil
},
}
store := &recordingUserStore{}
deps := newOIDCTestDeps(provider)
deps.UserStore = store
r := newOIDCRouter(deps)
cbW := runCallbackWithProvider(t, r)
require.Equal(t, http.StatusFound, cbW.Code,
"login should succeed even without email claim; body=%s", cbW.Body.String())
got := store.last()
require.NotNil(t, got, "Upsert should have been called")
assert.Equal(t, "sub-uuid-1", got.ID)
assert.Equal(t, "sub-uuid-1@noemail.visiona.local", got.Email,
"empty email claim must be provisioned with sub-based fallback placeholder")
assert.True(t, isFallbackEmail(got.Email), "provisioned email must be flagged as fallback")
}
// TestOIDCCallback_EmailPresent_UsesRealEmail 驗證 email claim 非空時用真 email、不套 fallback。
// (未來 MC 修好發 email 後的行為。)
func TestOIDCCallback_EmailPresent_UsesRealEmail(t *testing.T) {
provider := &mockOIDCProvider{
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
return &oidc.Claims{Subject: "sub-uuid-2", Email: "real@example.com", Name: "Carol", Nonce: nonce}, nil
},
}
store := &recordingUserStore{}
deps := newOIDCTestDeps(provider)
deps.UserStore = store
r := newOIDCRouter(deps)
cbW := runCallbackWithProvider(t, r)
require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String())
got := store.last()
require.NotNil(t, got)
assert.Equal(t, "real@example.com", got.Email, "non-empty email claim must be used as-is")
assert.False(t, isFallbackEmail(got.Email), "real email must not be flagged as fallback")
}
// TestResolveProvisionEmail 驗證 fallback email 計算邏輯(格式正確、含 sub、unique-by-sub
func TestResolveProvisionEmail(t *testing.T) {
t.Run("empty_email_uses_sub_fallback", func(t *testing.T) {
email, isFallback := resolveProvisionEmail("abc-123", "")
assert.Equal(t, "abc-123@noemail.visiona.local", email)
assert.True(t, isFallback)
assert.True(t, isFallbackEmail(email))
})
t.Run("real_email_passthrough", func(t *testing.T) {
email, isFallback := resolveProvisionEmail("abc-123", "x@y.com")
assert.Equal(t, "x@y.com", email)
assert.False(t, isFallback)
assert.False(t, isFallbackEmail(email))
})
t.Run("fallback_is_unique_per_sub", func(t *testing.T) {
e1, _ := resolveProvisionEmail("sub-A", "")
e2, _ := resolveProvisionEmail("sub-B", "")
assert.NotEqual(t, e1, e2, "different subs must yield different fallback emails (no unique-index clash)")
})
}
// ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ----
// TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。
func TestOIDCMiddleware_Allows_AuthenticatedSession(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
// 完整跑一次 login + callback 拿到登入 session
cookies := loginAndCallback(t, r, deps, provider)
// 訪 /api/auth/me — 應 200 + 帶 user info
meW := httptest.NewRecorder()
meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
for _, c := range cookies {
meReq.AddCookie(c)
}
r.ServeHTTP(meW, meReq)
require.Equal(t, http.StatusOK, meW.Code, "body=%s", meW.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(meW.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "user-123", data["user_id"])
assert.Equal(t, "alice@example.com", data["email"])
assert.Equal(t, "Alice", data["name"])
}
// TestOIDCMiddleware_Rejects_NoCookie 驗證沒 cookie → 401。
func TestOIDCMiddleware_Rejects_NoCookie(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "no_session")
}
// TestOIDCMiddleware_Rejects_PendingSession 驗證 pending sessionUserID 空)→ 401。
//
// 情境:使用者啟動 login 但還沒走完 callback只有 pending session cookie
// 就直接訪 /api/auth/me — 應該被拒絕,而不是被當已登入。
func TestOIDCMiddleware_Rejects_PendingSession(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
cookies := loginW.Result().Cookies()
meW := httptest.NewRecorder()
meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
for _, c := range cookies {
meReq.AddCookie(c)
}
r.ServeHTTP(meW, meReq)
assert.Equal(t, http.StatusUnauthorized, meW.Code)
assert.Contains(t, meW.Body.String(), "session_not_authenticated")
}
// TestOIDCLogout_ClearsSession 驗證 logout 200 + 清 cookie。
func TestOIDCLogout_ClearsSession(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
cookies := loginAndCallback(t, r, deps, provider)
// POST /api/auth/logout
logoutW := httptest.NewRecorder()
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
r.ServeHTTP(logoutW, logoutReq)
assert.Equal(t, http.StatusOK, logoutW.Code)
// Set-Cookie 應該帶過期 attribute
respCookies := logoutW.Result().Cookies()
var cleared *http.Cookie
for _, c := range respCookies {
if c.Name == "visiona_session" {
cleared = c
break
}
}
require.NotNil(t, cleared, "expected visiona_session clearing cookie")
assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie")
// 之後 /api/auth/me 應該 401
meW := httptest.NewRecorder()
meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
for _, c := range cookies {
meReq.AddCookie(c)
}
r.ServeHTTP(meW, meReq)
assert.Equal(t, http.StatusUnauthorized, meW.Code)
}
// TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset 驗證未設定 OIDCLogoutURL 時,
// logout response 不含 idp_logout 欄位(向下相容,只清本地 session
func TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
// 不設 OIDCLogoutURL預設空字串
require.Empty(t, deps.OIDCLogoutURL)
r := newOIDCRouter(deps)
cookies := loginAndCallback(t, r, deps, provider)
logoutW := httptest.NewRecorder()
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
r.ServeHTTP(logoutW, logoutReq)
require.Equal(t, http.StatusOK, logoutW.Code)
// data.success == true 且不含 idp_logout
var env struct {
Success bool `json:"success"`
Data struct {
Success bool `json:"success"`
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
assert.True(t, env.Data.Success)
assert.Nil(t, env.Data.IDPLogout, "未設定 OIDCLogoutURL 時不應回 idp_logout")
// 原始 JSON 不應出現 idp_logout keyomitempty 驗證)
assert.NotContains(t, logoutW.Body.String(), "idp_logout")
}
// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後,
// logout response 含 idp_logoutURL + Method=GET且仍清本地 session。
func TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
// MC Web :7880 logout 入口(不是 :7850 Api
const mcLogoutURL = "https://stage-9527.innovedus.com:7880/account/logout"
deps.OIDCLogoutURL = mcLogoutURL
r := newOIDCRouter(deps)
cookies := loginAndCallback(t, r, deps, provider)
logoutW := httptest.NewRecorder()
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
r.ServeHTTP(logoutW, logoutReq)
require.Equal(t, http.StatusOK, logoutW.Code)
var env struct {
Data struct {
Success bool `json:"success"`
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
assert.True(t, env.Data.Success)
require.NotNil(t, env.Data.IDPLogout, "設定 OIDCLogoutURL 後應回 idp_logout")
assert.Equal(t, mcLogoutURL, env.Data.IDPLogout.URL)
assert.Equal(t, http.MethodGet, env.Data.IDPLogout.Method)
// 本地 session 仍要被清掉(清 cookie
var cleared *http.Cookie
for _, c := range logoutW.Result().Cookies() {
if c.Name == "visiona_session" {
cleared = c
break
}
}
require.NotNil(t, cleared, "expected visiona_session clearing cookie")
assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie")
}
// TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。
func TestOIDC_LegacyLogin_Returns410(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
r := newOIDCRouter(deps)
// POST /api/auth/login 在 OIDC 模式下不支援 — 但會先過 AuthMiddleware
// (沒帶 cookie 就 401。為了測 410 行為,先登入拿 cookie。
cookies := loginAndCallback(t, r, deps, provider)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusGone, w.Code)
assert.Contains(t, w.Body.String(), "GET /api/auth/login")
}
// TestNewRouterValidate_PanicsWithoutOIDC 驗證 OB5 起 NewRouter 在缺 OIDC 依賴時 panic。
func TestNewRouterValidate_PanicsWithoutOIDC(t *testing.T) {
t.Run("no provider, no manager", func(t *testing.T) {
assert.Panics(t, func() {
(&Deps{}).validate()
}, "缺兩個 OIDC 依賴應 panic")
})
t.Run("only provider", func(t *testing.T) {
assert.Panics(t, func() {
(&Deps{OIDCProvider: &mockOIDCProvider{}}).validate()
}, "缺 SessionManager 應 panic")
})
t.Run("only manager", func(t *testing.T) {
// 這裡的 SigningKey 長度必須 ≥ 32 bytesusersession.MinSigningKeyBytes否則 NewManager 會 panic。
d := &Deps{SessionManager: usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{SigningKey: []byte("test-key-test-key-test-key-1234!")})}
assert.Panics(t, func() {
d.validate()
}, "缺 OIDCProvider 應 panic")
})
t.Run("both set passes", func(t *testing.T) {
d := newOIDCTestDeps(&mockOIDCProvider{})
assert.NotPanics(t, func() {
d.validate()
})
})
}
// ---- helper: 共用的 login + callback 流程 --------------------------------
// loginAndCallback 跑完整 login → callback 流程,回傳已登入 session 的 cookie。
//
// Fix-A1session fixation 防護callback 完成時會 rotate session IDcookie 會被改寫。
// 因此優先回傳 callback 後的 Set-Cookie若 callback 沒寫新 cookie理論上不應該
// 才 fallback 用 login 階段的 cookie。
func loginAndCallback(t *testing.T, r *gin.Engine, deps Deps, _ *mockOIDCProvider) []*http.Cookie {
t.Helper()
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login?return_to=/", nil))
require.Equal(t, http.StatusFound, loginW.Code)
state := mustExtractStateFromLoginRedirect(t, loginW)
loginCookies := loginW.Result().Cookies()
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil)
for _, c := range loginCookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
require.Equal(t, http.StatusFound, cbW.Code, "callback failed: %s", cbW.Body.String())
// callback 完成後的 Set-Cookie 是 rotation 後的新 cookie用它做後續請求。
cbCookies := cbW.Result().Cookies()
if len(cbCookies) > 0 {
return cbCookies
}
return loginCookies
}
// mustExtractStateFromLoginRedirect 從 login redirect 的 Location 取出 state。
func mustExtractStateFromLoginRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
loc, err := url.Parse(w.Header().Get("Location"))
require.NoError(t, err)
state := loc.Query().Get("state")
require.NotEmpty(t, state)
return state
}