session-tracker-cleanup.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. let redisClientRef: any;
  3. const pipelineCalls: Array<unknown[]> = [];
  4. const makePipeline = () => {
  5. const pipeline = {
  6. zadd: vi.fn((...args: unknown[]) => {
  7. pipelineCalls.push(["zadd", ...args]);
  8. return pipeline;
  9. }),
  10. expire: vi.fn((...args: unknown[]) => {
  11. pipelineCalls.push(["expire", ...args]);
  12. return pipeline;
  13. }),
  14. setex: vi.fn((...args: unknown[]) => {
  15. pipelineCalls.push(["setex", ...args]);
  16. return pipeline;
  17. }),
  18. zremrangebyscore: vi.fn((...args: unknown[]) => {
  19. pipelineCalls.push(["zremrangebyscore", ...args]);
  20. return pipeline;
  21. }),
  22. zrange: vi.fn((...args: unknown[]) => {
  23. pipelineCalls.push(["zrange", ...args]);
  24. return pipeline;
  25. }),
  26. exists: vi.fn((...args: unknown[]) => {
  27. pipelineCalls.push(["exists", ...args]);
  28. return pipeline;
  29. }),
  30. exec: vi.fn(async () => {
  31. pipelineCalls.push(["exec"]);
  32. return [];
  33. }),
  34. };
  35. return pipeline;
  36. };
  37. vi.mock("@/lib/logger", () => ({
  38. logger: {
  39. debug: vi.fn(),
  40. info: vi.fn(),
  41. warn: vi.fn(),
  42. error: vi.fn(),
  43. trace: vi.fn(),
  44. },
  45. }));
  46. vi.mock("@/lib/redis", () => ({
  47. getRedisClient: () => redisClientRef,
  48. }));
  49. describe("SessionTracker - TTL and cleanup", () => {
  50. const nowMs = 1_700_000_000_000;
  51. const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL;
  52. beforeEach(() => {
  53. vi.resetAllMocks();
  54. vi.resetModules();
  55. pipelineCalls.length = 0;
  56. vi.useFakeTimers();
  57. vi.setSystemTime(new Date(nowMs));
  58. redisClientRef = {
  59. status: "ready",
  60. exists: vi.fn(async () => 1),
  61. type: vi.fn(async () => "zset"),
  62. del: vi.fn(async () => 1),
  63. zremrangebyscore: vi.fn(async () => 0),
  64. zrange: vi.fn(async () => []),
  65. pipeline: vi.fn(() => makePipeline()),
  66. };
  67. });
  68. afterEach(() => {
  69. vi.useRealTimers();
  70. if (ORIGINAL_SESSION_TTL === undefined) {
  71. delete process.env.SESSION_TTL;
  72. } else {
  73. process.env.SESSION_TTL = ORIGINAL_SESSION_TTL;
  74. }
  75. });
  76. describe("env-driven TTL", () => {
  77. it("should use SESSION_TTL env (seconds) converted to ms for cutoff calculation", async () => {
  78. // Set SESSION_TTL to 600 seconds (10 minutes)
  79. process.env.SESSION_TTL = "600";
  80. const { SessionTracker } = await import("@/lib/session-tracker");
  81. await SessionTracker.getGlobalSessionCount();
  82. // Should call zremrangebyscore with cutoff = now - 600*1000 = now - 600000
  83. const expectedCutoff = nowMs - 600 * 1000;
  84. expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
  85. "global:active_sessions",
  86. "-inf",
  87. expectedCutoff
  88. );
  89. });
  90. it("should default to 300 seconds (5 min) when SESSION_TTL not set", async () => {
  91. delete process.env.SESSION_TTL;
  92. const { SessionTracker } = await import("@/lib/session-tracker");
  93. await SessionTracker.getGlobalSessionCount();
  94. // Default: 300 seconds = 300000 ms
  95. const expectedCutoff = nowMs - 300 * 1000;
  96. expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
  97. "global:active_sessions",
  98. "-inf",
  99. expectedCutoff
  100. );
  101. });
  102. });
  103. describe("refreshSession - provider ZSET EXPIRE", () => {
  104. it("should set EXPIRE on provider ZSET with fallback TTL 3600", async () => {
  105. process.env.SESSION_TTL = "300";
  106. const { SessionTracker } = await import("@/lib/session-tracker");
  107. await SessionTracker.refreshSession("sess-123", 1, 42);
  108. // Check pipeline calls include expire for provider ZSET
  109. const providerExpireCall = pipelineCalls.find(
  110. (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions")
  111. );
  112. expect(providerExpireCall).toBeDefined();
  113. expect(providerExpireCall![2]).toBe(3600); // fallback TTL
  114. });
  115. it("should use SESSION_TTL when it exceeds 3600s for provider ZSET EXPIRE", async () => {
  116. process.env.SESSION_TTL = "7200"; // 2 hours > 3600
  117. const { SessionTracker } = await import("@/lib/session-tracker");
  118. await SessionTracker.refreshSession("sess-123", 1, 42);
  119. // Check pipeline calls include expire for provider ZSET with dynamic TTL
  120. const providerExpireCall = pipelineCalls.find(
  121. (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions")
  122. );
  123. expect(providerExpireCall).toBeDefined();
  124. expect(providerExpireCall![2]).toBe(7200); // should use SESSION_TTL when > 3600
  125. });
  126. it("should refresh session binding TTLs using env SESSION_TTL (not hardcoded 300)", async () => {
  127. process.env.SESSION_TTL = "600"; // 10 minutes
  128. const { SessionTracker } = await import("@/lib/session-tracker");
  129. await SessionTracker.refreshSession("sess-123", 1, 42);
  130. // Check expire calls for session bindings use 600 (env value), not 300
  131. const providerBindingExpire = pipelineCalls.find(
  132. (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:provider"
  133. );
  134. const keyBindingExpire = pipelineCalls.find(
  135. (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:key"
  136. );
  137. const lastSeenSetex = pipelineCalls.find(
  138. (call) => call[0] === "setex" && String(call[1]) === "session:sess-123:last_seen"
  139. );
  140. expect(providerBindingExpire).toBeDefined();
  141. expect(providerBindingExpire![2]).toBe(600);
  142. expect(keyBindingExpire).toBeDefined();
  143. expect(keyBindingExpire![2]).toBe(600);
  144. expect(lastSeenSetex).toBeDefined();
  145. expect(lastSeenSetex![2]).toBe(600);
  146. });
  147. });
  148. describe("refreshSession - probabilistic cleanup on write path", () => {
  149. it("should perform ZREMRANGEBYSCORE cleanup when probability gate hits", async () => {
  150. process.env.SESSION_TTL = "300";
  151. // Mock Math.random to always return 0 (below default 0.01 threshold)
  152. vi.spyOn(Math, "random").mockReturnValue(0);
  153. const { SessionTracker } = await import("@/lib/session-tracker");
  154. await SessionTracker.refreshSession("sess-123", 1, 42);
  155. // Should have zremrangebyscore call for provider ZSET cleanup
  156. const cleanupCall = pipelineCalls.find(
  157. (call) =>
  158. call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions")
  159. );
  160. expect(cleanupCall).toBeDefined();
  161. // Cutoff should be now - SESSION_TTL_MS
  162. const expectedCutoff = nowMs - 300 * 1000;
  163. expect(cleanupCall![2]).toBe("-inf");
  164. expect(cleanupCall![3]).toBe(expectedCutoff);
  165. });
  166. it("should skip cleanup when probability gate does not hit", async () => {
  167. process.env.SESSION_TTL = "300";
  168. // Mock Math.random to return 0.5 (above default 0.01 threshold)
  169. vi.spyOn(Math, "random").mockReturnValue(0.5);
  170. const { SessionTracker } = await import("@/lib/session-tracker");
  171. await SessionTracker.refreshSession("sess-123", 1, 42);
  172. // Should NOT have zremrangebyscore call
  173. const cleanupCall = pipelineCalls.find((call) => call[0] === "zremrangebyscore");
  174. expect(cleanupCall).toBeUndefined();
  175. });
  176. it("should use env-driven TTL for cleanup cutoff calculation", async () => {
  177. process.env.SESSION_TTL = "600"; // 10 minutes
  178. vi.spyOn(Math, "random").mockReturnValue(0);
  179. const { SessionTracker } = await import("@/lib/session-tracker");
  180. await SessionTracker.refreshSession("sess-123", 1, 42);
  181. const cleanupCall = pipelineCalls.find(
  182. (call) =>
  183. call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions")
  184. );
  185. expect(cleanupCall).toBeDefined();
  186. // Cutoff should be now - 600*1000
  187. const expectedCutoff = nowMs - 600 * 1000;
  188. expect(cleanupCall![3]).toBe(expectedCutoff);
  189. });
  190. });
  191. describe("countFromZSet - env-driven TTL", () => {
  192. it("should use env SESSION_TTL for cleanup cutoff in batch count", async () => {
  193. process.env.SESSION_TTL = "600";
  194. const { SessionTracker } = await import("@/lib/session-tracker");
  195. // getProviderSessionCountBatch uses SESSION_TTL internally
  196. await SessionTracker.getProviderSessionCountBatch([1, 2]);
  197. // Check pipeline zremrangebyscore calls use correct cutoff
  198. const cleanupCalls = pipelineCalls.filter((call) => call[0] === "zremrangebyscore");
  199. expect(cleanupCalls.length).toBeGreaterThan(0);
  200. const expectedCutoff = nowMs - 600 * 1000;
  201. for (const call of cleanupCalls) {
  202. expect(call[3]).toBe(expectedCutoff);
  203. }
  204. });
  205. });
  206. describe("getActiveSessions - env-driven TTL", () => {
  207. it("should use env SESSION_TTL for cleanup cutoff", async () => {
  208. process.env.SESSION_TTL = "600";
  209. const { SessionTracker } = await import("@/lib/session-tracker");
  210. await SessionTracker.getActiveSessions();
  211. const expectedCutoff = nowMs - 600 * 1000;
  212. expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
  213. "global:active_sessions",
  214. "-inf",
  215. expectedCutoff
  216. );
  217. });
  218. });
  219. describe("Fail-Open behavior", () => {
  220. it("refreshSession should not throw when Redis is not ready", async () => {
  221. redisClientRef.status = "end";
  222. const { SessionTracker } = await import("@/lib/session-tracker");
  223. await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined();
  224. });
  225. it("refreshSession should not throw when Redis is null", async () => {
  226. redisClientRef = null;
  227. const { SessionTracker } = await import("@/lib/session-tracker");
  228. await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined();
  229. });
  230. });
  231. });