從 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>
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package usersession
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// withFrozenNow 暫時把 nowFunc 換成回傳固定時間,return 還原 func。
|
||
//
|
||
// 這個 helper 解決「測 idle / absolute timeout 不能等真時鐘」的問題。
|
||
// 注意:nowFunc 是 package-level 變數,測試之間不可平行(用 t.Parallel 會 race)。
|
||
func withFrozenNow(t *testing.T, fixed time.Time) func() {
|
||
t.Helper()
|
||
orig := nowFunc
|
||
nowFunc = func() time.Time { return fixed }
|
||
return func() { nowFunc = orig }
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// Create / Get / Update / Delete 基本流程
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func TestInMemoryStore_CreateAndGet(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
if sess.ID == "" {
|
||
t.Fatalf("Create returned empty ID")
|
||
}
|
||
if sess.CreatedAt.IsZero() || sess.LastSeenAt.IsZero() {
|
||
t.Fatalf("CreatedAt/LastSeenAt should be set")
|
||
}
|
||
if !sess.CreatedAt.Equal(sess.LastSeenAt) {
|
||
t.Fatalf("Create: CreatedAt should == LastSeenAt initially, got %v vs %v",
|
||
sess.CreatedAt, sess.LastSeenAt)
|
||
}
|
||
|
||
got, err := store.Get(ctx, sess.ID)
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
if got.ID != sess.ID {
|
||
t.Fatalf("Get: ID mismatch want=%s got=%s", sess.ID, got.ID)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Get_NotFound(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
_, err := store.Get(context.Background(), "no-such-id")
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Get_EmptyID(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
_, err := store.Get(context.Background(), "")
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession for empty id, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 驗證 Get 回傳的是副本,外部修改不影響 store 內部狀態(避免 race)。
|
||
func TestInMemoryStore_Get_ReturnsCopy(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
got1, _ := store.Get(ctx, sess.ID)
|
||
got1.Email = "tampered@example.com"
|
||
got1.Extra = map[string]any{"x": "y"}
|
||
|
||
got2, _ := store.Get(ctx, sess.ID)
|
||
if got2.Email == "tampered@example.com" {
|
||
t.Fatalf("Get should return a copy; mutation leaked into store")
|
||
}
|
||
if got2.Extra != nil {
|
||
t.Fatalf("Get should return a copy; Extra map mutation leaked")
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Update_MovesLastSeenAt(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
// 把時間往前推 5 分鐘
|
||
t1 := t0.Add(5 * time.Minute)
|
||
nowFunc = func() time.Time { return t1 }
|
||
|
||
sess.UserID = "user-123"
|
||
sess.Email = "alice@example.com"
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update: %v", err)
|
||
}
|
||
if !sess.LastSeenAt.Equal(t1) {
|
||
t.Fatalf("Update should reflect new LastSeenAt back to caller, got %v want %v",
|
||
sess.LastSeenAt, t1)
|
||
}
|
||
|
||
got, _ := store.Get(ctx, sess.ID)
|
||
if got.UserID != "user-123" || got.Email != "alice@example.com" {
|
||
t.Fatalf("Update did not persist user fields: %+v", got)
|
||
}
|
||
if !got.LastSeenAt.Equal(t1) {
|
||
t.Fatalf("store LastSeenAt not advanced: got %v want %v", got.LastSeenAt, t1)
|
||
}
|
||
if !got.CreatedAt.Equal(t0) {
|
||
t.Fatalf("Update must not change CreatedAt: got %v want %v", got.CreatedAt, t0)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Update_NotFound(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
err := store.Update(context.Background(), &Session{ID: "ghost"})
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Update_NilSession(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession for nil, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_Delete(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
|
||
_, err := store.Get(ctx, sess.ID)
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("after Delete, Get should return ErrNoSession, got %v", err)
|
||
}
|
||
|
||
// 重複刪同一個 ID 應為 no-op
|
||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||
t.Fatalf("Delete on missing should be no-op, got %v", err)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// CleanupExpired
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func TestInMemoryStore_CleanupExpired_Idle(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
|
||
sess1, _ := store.Create(ctx) // LastSeenAt = t0
|
||
sess2, _ := store.Create(ctx) // LastSeenAt = t0
|
||
|
||
// 把時間往前推 2 小時
|
||
nowFunc = func() time.Time { return t0.Add(2 * time.Hour) }
|
||
|
||
// 把 sess2 的 LastSeenAt 更新到 now(透過 Update)
|
||
if err := store.Update(ctx, sess2); err != nil {
|
||
t.Fatalf("Update sess2: %v", err)
|
||
}
|
||
|
||
// idleTimeout = 1h → sess1(idle 2h)應被清,sess2(idle 0)保留。
|
||
removed, err := store.CleanupExpired(ctx, 1*time.Hour, 0)
|
||
if err != nil {
|
||
t.Fatalf("CleanupExpired: %v", err)
|
||
}
|
||
if removed != 1 {
|
||
t.Fatalf("expected to remove 1 session, got %d", removed)
|
||
}
|
||
if _, err := store.Get(ctx, sess1.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("sess1 should be gone")
|
||
}
|
||
if _, err := store.Get(ctx, sess2.ID); err != nil {
|
||
t.Fatalf("sess2 should remain, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_CleanupExpired_Absolute(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
|
||
sess1, _ := store.Create(ctx) // CreatedAt = t0
|
||
// 推 8 天
|
||
nowFunc = func() time.Time { return t0.Add(8 * 24 * time.Hour) }
|
||
|
||
// 持續 Update 讓 idle 永遠是新的,但 absolute 7d 已超
|
||
if err := store.Update(ctx, sess1); err != nil {
|
||
t.Fatalf("Update: %v", err)
|
||
}
|
||
|
||
removed, err := store.CleanupExpired(ctx, 24*time.Hour, 7*24*time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("CleanupExpired: %v", err)
|
||
}
|
||
if removed != 1 {
|
||
t.Fatalf("expected absolute timeout to clear sess1, removed=%d", removed)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryStore_CleanupExpired_ZeroDisablesCheck(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
_, _ = store.Create(ctx)
|
||
|
||
// 推 100 天
|
||
nowFunc = func() time.Time { return t0.Add(100 * 24 * time.Hour) }
|
||
|
||
// idleTimeout=0 + absoluteTimeout=0 → 兩個檢查都跳過 → 不刪
|
||
removed, err := store.CleanupExpired(ctx, 0, 0)
|
||
if err != nil {
|
||
t.Fatalf("CleanupExpired: %v", err)
|
||
}
|
||
if removed != 0 {
|
||
t.Fatalf("zero timeouts should disable cleanup, but removed=%d", removed)
|
||
}
|
||
if store.Len() != 1 {
|
||
t.Fatalf("session should remain, got Len=%d", store.Len())
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// Race detector smoke test
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
// TestInMemoryStore_ConcurrentAccess 用 race detector 跑時驗證並發安全。
|
||
//
|
||
// goroutines 同時 Create / Get / Update / Delete / CleanupExpired,
|
||
// 必須無 data race(go test -race 會抓)。
|
||
func TestInMemoryStore_ConcurrentAccess(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
ctx := context.Background()
|
||
const goroutines = 20
|
||
const iterations = 50
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(goroutines)
|
||
|
||
for i := 0; i < goroutines; i++ {
|
||
go func() {
|
||
defer wg.Done()
|
||
for j := 0; j < iterations; j++ {
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Errorf("Create: %v", err)
|
||
return
|
||
}
|
||
_, _ = store.Get(ctx, sess.ID)
|
||
|
||
sess.UserID = "u"
|
||
_ = store.Update(ctx, sess)
|
||
|
||
_ = store.Delete(ctx, sess.ID)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// 同時跑 cleanup
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for j := 0; j < iterations; j++ {
|
||
_, _ = store.CleanupExpired(ctx, time.Nanosecond, 0)
|
||
}
|
||
}()
|
||
|
||
wg.Wait()
|
||
}
|
||
|
||
// 驗證 ContextCancelled 會被尊重。
|
||
func TestInMemoryStore_RespectsContext(t *testing.T) {
|
||
store := NewInMemoryStore()
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
if _, err := store.Create(ctx); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Create should respect cancelled ctx, got %v", err)
|
||
}
|
||
if _, err := store.Get(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Get should respect cancelled ctx, got %v", err)
|
||
}
|
||
if err := store.Update(ctx, &Session{ID: "x"}); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Update should respect cancelled ctx, got %v", err)
|
||
}
|
||
if err := store.Delete(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Delete should respect cancelled ctx, got %v", err)
|
||
}
|
||
if _, err := store.CleanupExpired(ctx, time.Hour, time.Hour); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("CleanupExpired should respect cancelled ctx, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 驗證 generated session ID 是 base64url 且不重複。
|
||
func TestGenerateSessionID_Uniqueness(t *testing.T) {
|
||
const n = 1000
|
||
seen := make(map[string]bool, n)
|
||
for i := 0; i < n; i++ {
|
||
id, err := generateSessionID()
|
||
if err != nil {
|
||
t.Fatalf("generateSessionID: %v", err)
|
||
}
|
||
if len(id) != 43 { // 32 bytes → base64url RawURLEncoding 後 43 字元
|
||
t.Fatalf("unexpected id length: %d (id=%q)", len(id), id)
|
||
}
|
||
if seen[id] {
|
||
t.Fatalf("duplicate session id: %s", id)
|
||
}
|
||
seen[id] = true
|
||
}
|
||
}
|