| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- const { getRedisClientMock, loggerMock } = vi.hoisted(() => ({
- getRedisClientMock: vi.fn(),
- loggerMock: {
- error: vi.fn(),
- warn: vi.fn(),
- info: vi.fn(),
- debug: vi.fn(),
- trace: vi.fn(),
- },
- }));
- vi.mock("@/lib/redis", () => ({
- getRedisClient: getRedisClientMock,
- }));
- vi.mock("@/lib/logger", () => ({
- logger: loggerMock,
- }));
- class FakeRedis {
- status: "ready" | "end" = "ready";
- readonly store = new Map<string, string>();
- readonly ttlByKey = new Map<string, number>();
- throwOnGet = false;
- throwOnSetex = false;
- throwOnDel = false;
- readonly get = vi.fn(async (key: string) => {
- if (this.throwOnGet) throw new Error("redis get failed");
- return this.store.get(key) ?? null;
- });
- readonly setex = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
- if (this.throwOnSetex) throw new Error("redis setex failed");
- this.store.set(key, value);
- this.ttlByKey.set(key, ttlSeconds);
- return "OK";
- });
- readonly del = vi.fn(async (key: string) => {
- if (this.throwOnDel) throw new Error("redis del failed");
- const existed = this.store.delete(key);
- this.ttlByKey.delete(key);
- return existed ? 1 : 0;
- });
- }
- describe("RedisSessionStore", () => {
- let redis: FakeRedis;
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
- vi.clearAllMocks();
- redis = new FakeRedis();
- getRedisClientMock.mockReturnValue(redis);
- });
- afterEach(() => {
- vi.useRealTimers();
- });
- it("create() returns session data with generated sessionId", async () => {
- const { DEFAULT_SESSION_TTL } = await import("@/lib/auth-session-store");
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const store = new RedisSessionStore();
- const created = await store.create({ keyFingerprint: "fp-1", userId: 101, userRole: "user" });
- expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i);
- expect(created.keyFingerprint).toBe("fp-1");
- expect(created.userId).toBe(101);
- expect(created.userRole).toBe("user");
- expect(created.createdAt).toBe(new Date("2026-02-18T10:00:00.000Z").getTime());
- expect(created.expiresAt).toBe(created.createdAt + DEFAULT_SESSION_TTL * 1000);
- });
- it("read() returns data for existing session", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const session = {
- sessionId: "6b5097ff-a11e-4425-aad0-f57f7d2206fc",
- keyFingerprint: "fp-existing",
- userId: 7,
- userRole: "admin",
- createdAt: 1_700_000_000_000,
- expiresAt: 1_700_000_360_000,
- };
- redis.store.set(`cch:session:${session.sessionId}`, JSON.stringify(session));
- const store = new RedisSessionStore();
- const found = await store.read(session.sessionId);
- expect(found).toEqual(session);
- });
- it("read() returns null for non-existent session", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const store = new RedisSessionStore();
- const found = await store.read("missing-session");
- expect(found).toBeNull();
- });
- it("read() returns null when Redis read fails", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- redis.throwOnGet = true;
- const store = new RedisSessionStore();
- const found = await store.read("any-session");
- expect(found).toBeNull();
- expect(loggerMock.error).toHaveBeenCalled();
- });
- it("revoke() deletes session", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const sessionId = "f327f4f4-c95f-40ab-a017-af714df7a3f8";
- redis.store.set(`cch:session:${sessionId}`, JSON.stringify({ sessionId }));
- const store = new RedisSessionStore();
- const revoked = await store.revoke(sessionId);
- expect(revoked).toBe(true);
- expect(redis.store.has(`cch:session:${sessionId}`)).toBe(false);
- });
- it("rotate() creates new session and revokes old session", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const oldSession = {
- sessionId: "e7f7bf87-c3b9-4525-ac0c-c2cf7cd5006b",
- keyFingerprint: "fp-rotate",
- userId: 18,
- userRole: "user",
- createdAt: Date.now() - 10_000,
- expiresAt: Date.now() + 120_000,
- };
- redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
- const store = new RedisSessionStore();
- const rotated = await store.rotate(oldSession.sessionId);
- expect(rotated).not.toBeNull();
- expect(rotated?.sessionId).not.toBe(oldSession.sessionId);
- expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint);
- expect(rotated?.userId).toBe(oldSession.userId);
- expect(rotated?.userRole).toBe(oldSession.userRole);
- expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(false);
- expect(rotated ? redis.store.has(`cch:session:${rotated.sessionId}`) : false).toBe(true);
- });
- it("create() applies TTL and stores expiresAt deterministically", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const store = new RedisSessionStore();
- const created = await store.create(
- { keyFingerprint: "fp-ttl", userId: 9, userRole: "user" },
- 120
- );
- const key = `cch:session:${created.sessionId}`;
- expect(redis.ttlByKey.get(key)).toBe(120);
- expect(created.expiresAt - created.createdAt).toBe(120_000);
- });
- it("create() throws when Redis setex fails", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- redis.throwOnSetex = true;
- const store = new RedisSessionStore();
- await expect(
- store.create({ keyFingerprint: "fp-fail", userId: 3, userRole: "user" })
- ).rejects.toThrow("redis setex failed");
- expect(loggerMock.error).toHaveBeenCalled();
- });
- it("create() throws when Redis is not ready", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- redis.status = "end";
- const store = new RedisSessionStore();
- await expect(
- store.create({ keyFingerprint: "fp-noredis", userId: 4, userRole: "user" })
- ).rejects.toThrow("Redis not ready");
- });
- it("rotate() returns null when Redis setex fails during create", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const oldSession = {
- sessionId: "2a036ab4-902a-4f31-a782-ec18344e17b9",
- keyFingerprint: "fp-failure",
- userId: 3,
- userRole: "user",
- createdAt: Date.now(),
- expiresAt: Date.now() + 60_000,
- };
- redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
- redis.throwOnSetex = true;
- const store = new RedisSessionStore();
- const rotated = await store.rotate(oldSession.sessionId);
- expect(rotated).toBeNull();
- expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(true);
- expect(loggerMock.error).toHaveBeenCalled();
- });
- it("rotate() keeps new session when old session revocation fails", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const oldSession = {
- sessionId: "aaa-old-session",
- keyFingerprint: "fp-revoke-fail",
- userId: 5,
- userRole: "user",
- createdAt: Date.now() - 10_000,
- expiresAt: Date.now() + 120_000,
- };
- redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
- redis.throwOnDel = true;
- const store = new RedisSessionStore();
- const rotated = await store.rotate(oldSession.sessionId);
- expect(rotated).not.toBeNull();
- expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint);
- expect(loggerMock.warn).toHaveBeenCalled();
- });
- it("rotate() returns null for already-expired session", async () => {
- const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
- const expiredSession = {
- sessionId: "bbb-expired-session",
- keyFingerprint: "fp-expired",
- userId: 6,
- userRole: "user",
- createdAt: Date.now() - 120_000,
- expiresAt: Date.now() - 1_000,
- };
- redis.store.set(`cch:session:${expiredSession.sessionId}`, JSON.stringify(expiredSession));
- const store = new RedisSessionStore();
- const rotated = await store.rotate(expiredSession.sessionId);
- expect(rotated).toBeNull();
- expect(loggerMock.warn).toHaveBeenCalledWith(
- "[AuthSessionStore] Cannot rotate expired session",
- expect.objectContaining({ sessionId: expiredSession.sessionId })
- );
- });
- });
|