jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

338 lines
10 KiB
Go
Raw Permalink 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.

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 → sess1idle 2h應被清sess2idle 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 racego 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
}
}