session-tracker-cleanup.test.ts 9.8 KB

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