package usersession import ( "context" "errors" "sync" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) // setupRedisStore 起一個 in-process miniredis(純 Go,不需 docker),回傳: // - store:RedisUserSessionStore(idle / absolute TTL 由 caller 指定) // - mr:miniredis 實例(可呼叫 FastForward / SetTime / TTL 模擬時間與檢查 key TTL) // // miniredis 與本 package 的 nowFunc 是「兩個時鐘」: // - store 算 absolute deadline 用 nowFunc() // - miniredis 算 key TTL 用自己的內部時鐘 // // 測雙 TTL 時兩者都要同步推進(advanceTime helper 一次推兩個)。 func setupRedisStore(t *testing.T, idle, absolute time.Duration) (*RedisUserSessionStore, *miniredis.Miniredis) { t.Helper() mr := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewRedisUserSessionStore(client, idle, absolute) return store, mr } // clock 追蹤「目前已從 base 推進多少」,讓 advanceTime 能對 miniredis 的相對 TTL // 做增量 FastForward(FastForward 是「把所有 TTL 減去 duration」,必須用增量、不能用絕對 delta)。 type clock struct { base time.Time elapsed time.Duration // 已推進的累計量 } // advanceTo 把時間推進到 base+delta(delta 必須 >= 目前 elapsed,單調遞增)。 // // 同步推進兩個時鐘: // - nowFunc:給 store 算 absolute deadline(app 端邏輯)。 // - miniredis:用增量 FastForward 推相對 TTL(key 自動過期)+ SetTime 對齊 EXPIREAT 基準。 func (c *clock) advanceTo(t *testing.T, mr *miniredis.Miniredis, delta time.Duration) time.Time { t.Helper() if delta < c.elapsed { t.Fatalf("clock.advanceTo must be monotonic: delta=%v < elapsed=%v", delta, c.elapsed) } inc := delta - c.elapsed c.elapsed = delta newNow := c.base.Add(delta) nowFunc = func() time.Time { return newNow } mr.SetTime(newNow) mr.FastForward(inc) // 相對 TTL 增量推進,<=0 的 key 即過期 return newNow } // ───────────────────────────────────────────────────────── // 4.5 對齊既有 in-memory test:Create / Get / Update / Delete 基本行為 // ───────────────────────────────────────────────────────── func TestRedisStore_CreateAndGet(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) 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 TestRedisStore_Get_NotFound(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) _, err := store.Get(context.Background(), "no-such-id") if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession, got %v", err) } } func TestRedisStore_Get_EmptyID(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) _, err := store.Get(context.Background(), "") if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession for empty id, got %v", err) } } // Get 回傳的是副本,外部修改不影響 store 內部狀態。 func TestRedisStore_Get_ReturnsCopy(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) 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 TestRedisStore_Update_MovesLastSeenAt_KeepsCreatedAt(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() sess, _ := store.Create(ctx) // 往前推 5 分鐘(同步推進 nowFunc 與 miniredis 時鐘)。 clk := &clock{base: t0} t1 := clk.advanceTo(t, mr, 5*time.Minute) 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) } } // Update 不可「順便建立」不存在的 session(用 SET XX 達成)。 func TestRedisStore_Update_NotFound(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) err := store.Update(context.Background(), &Session{ID: "ghost", CreatedAt: nowFunc(), LastSeenAt: nowFunc()}) if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession, got %v", err) } } func TestRedisStore_Update_NilSession(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession for nil, got %v", err) } } func TestRedisStore_Delete_Idempotent(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() sess, _ := store.Create(ctx) if err := store.Delete(ctx, sess.ID); err != nil { t.Fatalf("Delete: %v", err) } if _, err := store.Get(ctx, sess.ID); !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) } // 刪空 ID 也是 no-op。 if err := store.Delete(ctx, ""); err != nil { t.Fatalf("Delete empty id should be no-op, got %v", err) } } // Extra map 與所有欄位(含 OIDC pending + token snapshot)round-trip。 func TestRedisStore_Extra_And_Fields_RoundTrip(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() sess, _ := store.Create(ctx) sess.UserID = "u-9" sess.Email = "bob@example.com" sess.Name = "Bob" sess.OIDCState = "state-xyz" sess.OIDCNonce = "nonce-abc" sess.OIDCCodeVerifier = "verifier-123" sess.AccessToken = "at-secret" sess.IDTokenRaw = "idt-raw" sess.Extra = map[string]any{ "return_to": "/dashboard", "count": float64(3), // JSON number round-trips as float64 "flag": true, } if err := store.Update(ctx, sess); err != nil { t.Fatalf("Update: %v", err) } got, err := store.Get(ctx, sess.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.UserID != "u-9" || got.Email != "bob@example.com" || got.Name != "Bob" { t.Fatalf("identity fields mismatch: %+v", got) } if got.OIDCState != "state-xyz" || got.OIDCNonce != "nonce-abc" || got.OIDCCodeVerifier != "verifier-123" { t.Fatalf("OIDC pending fields mismatch: %+v", got) } if got.AccessToken != "at-secret" || got.IDTokenRaw != "idt-raw" { t.Fatalf("token snapshot fields mismatch: %+v", got) } if got.Extra["return_to"] != "/dashboard" || got.Extra["count"] != float64(3) || got.Extra["flag"] != true { t.Fatalf("Extra round-trip mismatch: %+v", got.Extra) } } // context 取消應被尊重。 func TestRedisStore_RespectsContext(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) 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) } } // CleanupExpired 在 Redis 模式是 no-op(靠 key TTL)。 func TestRedisStore_CleanupExpired_NoOp(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() _, _ = store.Create(ctx) removed, err := store.CleanupExpired(ctx, time.Hour, time.Hour) if err != nil { t.Fatalf("CleanupExpired: %v", err) } if removed != 0 { t.Fatalf("Redis CleanupExpired should be no-op, removed=%d", removed) } } // ───────────────────────────────────────────────────────── // 4.6 雙 TTL:真 TTL 過期、idle vs absolute(用 miniredis FastForward 模擬時間) // ───────────────────────────────────────────────────────── // 建立後 key 的 Redis TTL 應為 idle(idle < absolute 時)。 func TestRedisStore_TTL_OnCreate_IsIdle(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() idle := 24 * time.Hour store, mr := setupRedisStore(t, idle, 168*time.Hour) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) ttl := mr.TTL(redisKey(sess.ID)) if ttl != idle { t.Fatalf("create TTL should be idle(%v), got %v", idle, ttl) } } // idle 過期:閒置超過 idle → key 被 Redis 自動清掉 → Get 回 ErrNoSession。 func TestRedisStore_IdleExpiry(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) // 閒置 2 小時(> idle 1h),不做任何 Update。 clk := &clock{base: t0} clk.advanceTo(t, mr, 2*time.Hour) if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { t.Fatalf("idle-expired session should be gone, got %v", err) } } // idle 續期:在 idle 內持續 Update → 不會因 idle 過期。 func TestRedisStore_IdleRenewedByUpdate(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) // 每 30 分鐘 Update 一次,共推進 2 小時 —— 每次都在 idle(1h) 內續期。 clk := &clock{base: t0} for i := 1; i <= 4; i++ { clk.advanceTo(t, mr, time.Duration(i)*30*time.Minute) if err := store.Update(ctx, sess); err != nil { t.Fatalf("Update #%d at +%dm: %v", i, i*30, err) } } // 累計 2h 但持續活躍 → 應仍存在。 if _, err := store.Get(ctx, sess.ID); err != nil { t.Fatalf("actively-used session should remain, got %v", err) } } // absolute 過期:即使持續 Update(idle 永遠新),超過 absolute 後仍應失效。 func TestRedisStore_AbsoluteExpiry_DespiteActivity(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() // idle 1h、absolute 3h。每 30 分鐘 Update(idle 永不過期),但 3h 後 absolute 應砍掉。 store, mr := setupRedisStore(t, 1*time.Hour, 3*time.Hour) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) // 在 absolute 內持續活躍(+30m ~ +180m)。最後一次 Update 落在 absolute deadline 後。 clk := &clock{base: t0} var lastUpdateErr error for d := 30 * time.Minute; d <= 210*time.Minute; d += 30 * time.Minute { clk.advanceTo(t, mr, d) lastUpdateErr = store.Update(ctx, sess) } // 超過 absolute(3h)後 Update 應回 ErrNoSession(absolute 砍掉)。 if !errors.Is(lastUpdateErr, ErrNoSession) { t.Fatalf("Update past absolute deadline should return ErrNoSession, got %v", lastUpdateErr) } if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { t.Fatalf("absolute-expired session should be gone, got %v", err) } } // absolute 上限封頂:idle 續期時 key TTL 不可超過「距 absolute deadline 的剩餘」。 func TestRedisStore_UpdateTTL_CappedByAbsolute(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() // idle 2h、absolute 150m。在 +1h 時 Update(key 仍在,idle 未過): // idle 想續成 2h,但距 absolute 只剩 90m,故 TTL 應被封頂為 90m(< idle 2h)。 store, mr := setupRedisStore(t, 2*time.Hour, 150*time.Minute) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) clk := &clock{base: t0} clk.advanceTo(t, mr, 1*time.Hour) if err := store.Update(ctx, sess); err != nil { t.Fatalf("Update at +1h: %v", err) } ttl := mr.TTL(redisKey(sess.ID)) if ttl != 90*time.Minute { t.Fatalf("TTL at +1h should be capped to absolute remaining(90m), got %v", ttl) } } // key 命名 / 隔離:不同 session 的 key 互不干擾,且都帶 prefix。 func TestRedisStore_KeyNamingAndIsolation(t *testing.T) { store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() s1, _ := store.Create(ctx) s2, _ := store.Create(ctx) if !mr.Exists(redisKey(s1.ID)) || !mr.Exists(redisKey(s2.ID)) { t.Fatalf("both session keys should exist with prefix %q", redisKeyPrefix) } // 刪 s1 不影響 s2。 if err := store.Delete(ctx, s1.ID); err != nil { t.Fatalf("Delete s1: %v", err) } if mr.Exists(redisKey(s1.ID)) { t.Fatalf("s1 key should be gone") } if _, err := store.Get(ctx, s2.ID); err != nil { t.Fatalf("s2 should remain after deleting s1, got %v", err) } } // ───────────────────────────────────────────────────────── // 4.7 邊界 + 併發 // ───────────────────────────────────────────────────────── // 永不過期設定:idle=0 且 absolute=0 → key 無 TTL(PERSIST 語意)。 func TestRedisStore_NeverExpires_WhenBothZero(t *testing.T) { store, mr := setupRedisStore(t, 0, 0) ctx := context.Background() sess, _ := store.Create(ctx) ttl := mr.TTL(redisKey(sess.ID)) if ttl != 0 { // miniredis TTL=0 代表無到期時間 t.Fatalf("with idle=abs=0, key should have no TTL, got %v", ttl) } // 推 100 天仍在。 mr.FastForward(100 * 24 * time.Hour) if _, err := store.Get(ctx, sess.ID); err != nil { t.Fatalf("no-TTL session should remain after 100d, got %v", err) } } // 只設 absolute(idle=0):key TTL = absolute;過期後消失。 func TestRedisStore_OnlyAbsolute(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store, mr := setupRedisStore(t, 0, 2*time.Hour) mr.SetTime(t0) ctx := context.Background() sess, _ := store.Create(ctx) if ttl := mr.TTL(redisKey(sess.ID)); ttl != 2*time.Hour { t.Fatalf("with idle=0, create TTL should be absolute(2h), got %v", ttl) } clk := &clock{base: t0} clk.advanceTo(t, mr, 3*time.Hour) if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { t.Fatalf("session past absolute should be gone, got %v", err) } } // 連線中斷:Redis 不可達時 store 方法回非 nil error(不 panic、不靜默成功)。 func TestRedisStore_ConnectionDown(t *testing.T) { store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() sess, _ := store.Create(ctx) mr.Close() // 模擬 Redis 掛掉 if _, err := store.Create(ctx); err == nil { t.Fatalf("Create should error when redis is down") } if _, err := store.Get(ctx, sess.ID); err == nil { t.Fatalf("Get should error when redis is down") } if err := store.Update(ctx, sess); err == nil { t.Fatalf("Update should error when redis is down") } if err := store.Delete(ctx, sess.ID); err == nil { t.Fatalf("Delete should error when redis is down") } } // 併發 smoke test(race detector 抓 data race)。 // 注意:本測試不凍結 nowFunc(避免與其他凍結時間的測試 race;nowFunc 是 package 變數)。 func TestRedisStore_ConcurrentAccess(t *testing.T) { store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) ctx := context.Background() const goroutines = 16 const iterations = 30 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) } }() } wg.Wait() } // TestRedisStore_EffectiveTTL_BoundaryDecision 直接斷言 effectiveTTL 的回傳值與 // save 的「過期 vs PERSIST」決策邊界,**不繞 miniredis**(純決策邏輯,不需 docker)。 // // 守的是 Minor-1 修正:當有 absolute 上限時,TTL 算到 0 ns(now 恰好對齊 deadline) // 不能被當成 PERSIST,否則 key 變永不過期、繞過 absolute 上限。 func TestRedisStore_EffectiveTTL_BoundaryDecision(t *testing.T) { base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) const ( idle = 24 * time.Hour abs = 168 * time.Hour ) cases := []struct { name string idleTTL time.Duration absoluteTTL time.Duration now time.Time createdAt time.Time wantTTL time.Duration // effectiveTTL 期望回傳 wantExpired bool // save 是否應視為已過期(回 ErrSessionExpired) wantPersist bool // 是否為合法 PERSIST(ttl==0 但 absoluteTTL==0) }{ { // idle + absolute 皆停用 → 合法 PERSIST(ttl 0、不過期)。 name: "both_disabled_persist", idleTTL: 0, absoluteTTL: 0, now: base, createdAt: base, wantTTL: 0, wantExpired: false, wantPersist: true, }, { // 有 absolute,now 恰好對齊 deadline(absRemaining == 0 ns)→ 必須視為過期, // 不能當 PERSIST。這是 Minor-1 的核心邊界。 name: "absolute_now_equals_deadline", idleTTL: 0, absoluteTTL: abs, now: base.Add(abs), createdAt: base, wantTTL: 0, wantExpired: true, wantPersist: false, }, { // deadline 前 1 ns → 還沒到期,TTL = 1 ns。 name: "absolute_one_ns_before_deadline", idleTTL: 0, absoluteTTL: abs, now: base.Add(abs - 1), createdAt: base, wantTTL: 1, wantExpired: false, wantPersist: false, }, { // deadline 後 1 ns → 已過期,TTL = -1 ns。 name: "absolute_one_ns_after_deadline", idleTTL: 0, absoluteTTL: abs, now: base.Add(abs + 1), createdAt: base, wantTTL: -1, wantExpired: true, wantPersist: false, }, { // idle+absolute 皆啟用、剛建立 → TTL 取較小的 idle(idle < abs remaining)。 name: "both_enabled_takes_idle", idleTTL: idle, absoluteTTL: abs, now: base, createdAt: base, wantTTL: idle, wantExpired: false, wantPersist: false, }, { // idle+absolute 皆啟用、now 對齊 absolute deadline → absRemaining 0 < idle, // 取 absRemaining(0) → 仍須視為過期(有 absolute 上限)。 name: "both_enabled_now_equals_abs_deadline", idleTTL: idle, absoluteTTL: abs, now: base.Add(abs), createdAt: base, wantTTL: 0, wantExpired: true, wantPersist: false, }, { // 只有 idle、無 absolute → 永遠回 idle,不受 deadline 概念影響、不過期。 name: "only_idle", idleTTL: idle, absoluteTTL: 0, now: base.Add(100 * time.Hour), createdAt: base, wantTTL: idle, wantExpired: false, wantPersist: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := NewRedisUserSessionStore(nil, tc.idleTTL, tc.absoluteTTL) gotTTL := s.effectiveTTL(tc.now, tc.createdAt) if gotTTL != tc.wantTTL { t.Errorf("effectiveTTL = %v, want %v", gotTTL, tc.wantTTL) } // 重現 save 的過期決策(不需 Redis client,純比較): // 有 absolute 上限且 ttl <= 0 → 過期;否則放行(ttl == 0 在無 absolute 時為 PERSIST)。 gotExpired := gotTTL <= 0 && s.absoluteTTL > 0 if gotExpired != tc.wantExpired { t.Errorf("save expiry decision = %v, want %v (ttl=%v, absoluteTTL=%v)", gotExpired, tc.wantExpired, gotTTL, s.absoluteTTL) } // PERSIST = ttl 0 且非過期決策(即 absoluteTTL == 0)。 gotPersist := gotTTL == 0 && !gotExpired if gotPersist != tc.wantPersist { t.Errorf("PERSIST decision = %v, want %v", gotPersist, tc.wantPersist) } }) } }