session-store.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. const { getRedisClientMock, loggerMock } = vi.hoisted(() => ({
  3. getRedisClientMock: vi.fn(),
  4. loggerMock: {
  5. error: vi.fn(),
  6. warn: vi.fn(),
  7. info: vi.fn(),
  8. debug: vi.fn(),
  9. trace: vi.fn(),
  10. },
  11. }));
  12. vi.mock("@/lib/redis", () => ({
  13. getRedisClient: getRedisClientMock,
  14. }));
  15. vi.mock("@/lib/logger", () => ({
  16. logger: loggerMock,
  17. }));
  18. class FakeRedis {
  19. status: "ready" | "end" = "ready";
  20. readonly store = new Map<string, string>();
  21. readonly ttlByKey = new Map<string, number>();
  22. throwOnGet = false;
  23. throwOnSetex = false;
  24. throwOnDel = false;
  25. readonly get = vi.fn(async (key: string) => {
  26. if (this.throwOnGet) throw new Error("redis get failed");
  27. return this.store.get(key) ?? null;
  28. });
  29. readonly setex = vi.fn(async (key: string, ttlSeconds: number, value: string) => {
  30. if (this.throwOnSetex) throw new Error("redis setex failed");
  31. this.store.set(key, value);
  32. this.ttlByKey.set(key, ttlSeconds);
  33. return "OK";
  34. });
  35. readonly del = vi.fn(async (key: string) => {
  36. if (this.throwOnDel) throw new Error("redis del failed");
  37. const existed = this.store.delete(key);
  38. this.ttlByKey.delete(key);
  39. return existed ? 1 : 0;
  40. });
  41. }
  42. describe("RedisSessionStore", () => {
  43. let redis: FakeRedis;
  44. beforeEach(() => {
  45. vi.useFakeTimers();
  46. vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
  47. vi.clearAllMocks();
  48. redis = new FakeRedis();
  49. getRedisClientMock.mockReturnValue(redis);
  50. });
  51. afterEach(() => {
  52. vi.useRealTimers();
  53. });
  54. it("create() returns session data with generated sessionId", async () => {
  55. const { DEFAULT_SESSION_TTL } = await import("@/lib/auth-session-store");
  56. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  57. const store = new RedisSessionStore();
  58. const created = await store.create({ keyFingerprint: "fp-1", userId: 101, userRole: "user" });
  59. expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i);
  60. expect(created.keyFingerprint).toBe("fp-1");
  61. expect(created.userId).toBe(101);
  62. expect(created.userRole).toBe("user");
  63. expect(created.createdAt).toBe(new Date("2026-02-18T10:00:00.000Z").getTime());
  64. expect(created.expiresAt).toBe(created.createdAt + DEFAULT_SESSION_TTL * 1000);
  65. });
  66. it("read() returns data for existing session", async () => {
  67. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  68. const session = {
  69. sessionId: "6b5097ff-a11e-4425-aad0-f57f7d2206fc",
  70. keyFingerprint: "fp-existing",
  71. userId: 7,
  72. userRole: "admin",
  73. createdAt: 1_700_000_000_000,
  74. expiresAt: 1_700_000_360_000,
  75. };
  76. redis.store.set(`cch:session:${session.sessionId}`, JSON.stringify(session));
  77. const store = new RedisSessionStore();
  78. const found = await store.read(session.sessionId);
  79. expect(found).toEqual(session);
  80. });
  81. it("read() returns null for non-existent session", async () => {
  82. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  83. const store = new RedisSessionStore();
  84. const found = await store.read("missing-session");
  85. expect(found).toBeNull();
  86. });
  87. it("read() returns null when Redis read fails", async () => {
  88. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  89. redis.throwOnGet = true;
  90. const store = new RedisSessionStore();
  91. const found = await store.read("any-session");
  92. expect(found).toBeNull();
  93. expect(loggerMock.error).toHaveBeenCalled();
  94. });
  95. it("revoke() deletes session", async () => {
  96. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  97. const sessionId = "f327f4f4-c95f-40ab-a017-af714df7a3f8";
  98. redis.store.set(`cch:session:${sessionId}`, JSON.stringify({ sessionId }));
  99. const store = new RedisSessionStore();
  100. const revoked = await store.revoke(sessionId);
  101. expect(revoked).toBe(true);
  102. expect(redis.store.has(`cch:session:${sessionId}`)).toBe(false);
  103. });
  104. it("rotate() creates new session and revokes old session", async () => {
  105. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  106. const oldSession = {
  107. sessionId: "e7f7bf87-c3b9-4525-ac0c-c2cf7cd5006b",
  108. keyFingerprint: "fp-rotate",
  109. userId: 18,
  110. userRole: "user",
  111. createdAt: Date.now() - 10_000,
  112. expiresAt: Date.now() + 120_000,
  113. };
  114. redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
  115. const store = new RedisSessionStore();
  116. const rotated = await store.rotate(oldSession.sessionId);
  117. expect(rotated).not.toBeNull();
  118. expect(rotated?.sessionId).not.toBe(oldSession.sessionId);
  119. expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint);
  120. expect(rotated?.userId).toBe(oldSession.userId);
  121. expect(rotated?.userRole).toBe(oldSession.userRole);
  122. expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(false);
  123. expect(rotated ? redis.store.has(`cch:session:${rotated.sessionId}`) : false).toBe(true);
  124. });
  125. it("create() applies TTL and stores expiresAt deterministically", async () => {
  126. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  127. const store = new RedisSessionStore();
  128. const created = await store.create(
  129. { keyFingerprint: "fp-ttl", userId: 9, userRole: "user" },
  130. 120
  131. );
  132. const key = `cch:session:${created.sessionId}`;
  133. expect(redis.ttlByKey.get(key)).toBe(120);
  134. expect(created.expiresAt - created.createdAt).toBe(120_000);
  135. });
  136. it("create() throws when Redis setex fails", async () => {
  137. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  138. redis.throwOnSetex = true;
  139. const store = new RedisSessionStore();
  140. await expect(
  141. store.create({ keyFingerprint: "fp-fail", userId: 3, userRole: "user" })
  142. ).rejects.toThrow("redis setex failed");
  143. expect(loggerMock.error).toHaveBeenCalled();
  144. });
  145. it("create() throws when Redis is not ready", async () => {
  146. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  147. redis.status = "end";
  148. const store = new RedisSessionStore();
  149. await expect(
  150. store.create({ keyFingerprint: "fp-noredis", userId: 4, userRole: "user" })
  151. ).rejects.toThrow("Redis not ready");
  152. });
  153. it("rotate() returns null when Redis setex fails during create", async () => {
  154. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  155. const oldSession = {
  156. sessionId: "2a036ab4-902a-4f31-a782-ec18344e17b9",
  157. keyFingerprint: "fp-failure",
  158. userId: 3,
  159. userRole: "user",
  160. createdAt: Date.now(),
  161. expiresAt: Date.now() + 60_000,
  162. };
  163. redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
  164. redis.throwOnSetex = true;
  165. const store = new RedisSessionStore();
  166. const rotated = await store.rotate(oldSession.sessionId);
  167. expect(rotated).toBeNull();
  168. expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(true);
  169. expect(loggerMock.error).toHaveBeenCalled();
  170. });
  171. it("rotate() keeps new session when old session revocation fails", async () => {
  172. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  173. const oldSession = {
  174. sessionId: "aaa-old-session",
  175. keyFingerprint: "fp-revoke-fail",
  176. userId: 5,
  177. userRole: "user",
  178. createdAt: Date.now() - 10_000,
  179. expiresAt: Date.now() + 120_000,
  180. };
  181. redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
  182. redis.throwOnDel = true;
  183. const store = new RedisSessionStore();
  184. const rotated = await store.rotate(oldSession.sessionId);
  185. expect(rotated).not.toBeNull();
  186. expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint);
  187. expect(loggerMock.warn).toHaveBeenCalled();
  188. });
  189. it("rotate() returns null for already-expired session", async () => {
  190. const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
  191. const expiredSession = {
  192. sessionId: "bbb-expired-session",
  193. keyFingerprint: "fp-expired",
  194. userId: 6,
  195. userRole: "user",
  196. createdAt: Date.now() - 120_000,
  197. expiresAt: Date.now() - 1_000,
  198. };
  199. redis.store.set(`cch:session:${expiredSession.sessionId}`, JSON.stringify(expiredSession));
  200. const store = new RedisSessionStore();
  201. const rotated = await store.rotate(expiredSession.sessionId);
  202. expect(rotated).toBeNull();
  203. expect(loggerMock.warn).toHaveBeenCalledWith(
  204. "[AuthSessionStore] Cannot rotate expired session",
  205. expect.objectContaining({ sessionId: expiredSession.sessionId })
  206. );
  207. });
  208. });