diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 8dbbdab93..edf11c95d 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -91,6 +91,12 @@ import { normalizeResetTime, } from "./time-utils"; +const SESSION_TTL_SECONDS = (() => { + const parsed = Number.parseInt(process.env.SESSION_TTL ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 300; +})(); +const SESSION_TTL_MS = SESSION_TTL_SECONDS * 1000; + interface CostLimit { amount: number | null; period: "5h" | "daily" | "weekly" | "monthly"; @@ -566,14 +572,14 @@ export class RateLimitService { const key = `provider:${providerId}:active_sessions`; const now = Date.now(); - // 执行 Lua 脚本:原子性检查 + 追踪(TC-041 修复版) const result = (await RateLimitService.redis.eval( CHECK_AND_TRACK_SESSION, 1, // KEYS count key, // KEYS[1] sessionId, // ARGV[1] limit.toString(), // ARGV[2] - now.toString() // ARGV[3] + now.toString(), // ARGV[3] + SESSION_TTL_MS.toString() // ARGV[4] )) as [number, number, number]; const [allowed, count, tracked] = result; diff --git a/src/lib/redis/lua-scripts.ts b/src/lib/redis/lua-scripts.ts index 0980a090a..0b8fec8cd 100644 --- a/src/lib/redis/lua-scripts.ts +++ b/src/lib/redis/lua-scripts.ts @@ -5,56 +5,66 @@ */ /** - * 原子性检查并发限制 + 追踪 Session(TC-041 修复版) + * Atomic concurrency check + session tracking (TC-041 fixed version) * - * 功能: - * 1. 清理过期 session(5 分钟前) - * 2. 检查 session 是否已追踪(避免重复计数) - * 3. 检查当前并发数是否超限 - * 4. 如果未超限,追踪新 session(原子操作) + * Features: + * 1. Cleanup expired sessions (based on TTL window) + * 2. Check if session is already tracked (avoid duplicate counting) + * 3. Check if current concurrency exceeds limit + * 4. If not exceeded, track new session (atomic operation) * * KEYS[1]: provider:${providerId}:active_sessions * ARGV[1]: sessionId - * ARGV[2]: limit(并发限制) - * ARGV[3]: now(当前时间戳,毫秒) + * ARGV[2]: limit (concurrency limit) + * ARGV[3]: now (current timestamp, ms) + * ARGV[4]: ttlMs (optional, cleanup window in ms, default 300000) * - * 返回值: - * - {1, count, 1} - 允许(新追踪),返回新的并发数和 tracked=1 - * - {1, count, 0} - 允许(已追踪),返回当前并发数和 tracked=0 - * - {0, count, 0} - 拒绝(超限),返回当前并发数和 tracked=0 + * Return: + * - {1, count, 1} - allowed (new tracking), returns new count and tracked=1 + * - {1, count, 0} - allowed (already tracked), returns current count and tracked=0 + * - {0, count, 0} - rejected (limit reached), returns current count and tracked=0 */ export const CHECK_AND_TRACK_SESSION = ` local provider_key = KEYS[1] local session_id = ARGV[1] local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) -local ttl = 300000 -- 5 分钟(毫秒) +local ttl = tonumber(ARGV[4]) or 300000 --- 1. 清理过期 session(5 分钟前) -local five_minutes_ago = now - ttl -redis.call('ZREMRANGEBYSCORE', provider_key, '-inf', five_minutes_ago) +-- Guard against invalid TTL (prevents clearing all sessions) +if ttl <= 0 then + ttl = 300000 +end + +-- 1. Cleanup expired sessions (TTL window ago) +local cutoff = now - ttl +redis.call('ZREMRANGEBYSCORE', provider_key, '-inf', cutoff) --- 2. 检查 session 是否已追踪 +-- 2. Check if session is already tracked local is_tracked = redis.call('ZSCORE', provider_key, session_id) --- 3. 获取当前并发数 +-- 3. Get current concurrency count local current_count = redis.call('ZCARD', provider_key) --- 4. 检查限制(排除已追踪的 session) +-- 4. Check limit (exclude already tracked session) if limit > 0 and not is_tracked and current_count >= limit then return {0, current_count, 0} -- {allowed=false, current_count, tracked=0} end --- 5. 追踪 session(ZADD 对已存在的成员只更新时间戳) +-- 5. Track session (ZADD updates timestamp for existing members) redis.call('ZADD', provider_key, now, session_id) -redis.call('EXPIRE', provider_key, 3600) -- 1 小时兜底 TTL --- 6. 返回成功 +-- 6. Set TTL based on session TTL (at least 1h to cover active sessions) +local ttl_seconds = math.floor(ttl / 1000) +local expire_ttl = math.max(3600, ttl_seconds) +redis.call('EXPIRE', provider_key, expire_ttl) + +-- 7. Return success if is_tracked then - -- 已追踪,计数不变 + -- Already tracked, count unchanged return {1, current_count, 0} -- {allowed=true, count, tracked=0} else - -- 新追踪,计数 +1 + -- New tracking, count +1 return {1, current_count + 1, 1} -- {allowed=true, new_count, tracked=1} end `; diff --git a/src/lib/session-tracker.ts b/src/lib/session-tracker.ts index 09960821b..eb52bbf17 100644 --- a/src/lib/session-tracker.ts +++ b/src/lib/session-tracker.ts @@ -17,7 +17,12 @@ import { getRedisClient } from "./redis"; * - user:${userId}:active_sessions (ZSET): 同上 */ export class SessionTracker { - private static readonly SESSION_TTL = 300000; // 5 分钟(毫秒) + private static readonly SESSION_TTL_SECONDS = (() => { + const parsed = Number.parseInt(process.env.SESSION_TTL ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 300; + })(); + private static readonly SESSION_TTL_MS = SessionTracker.SESSION_TTL_SECONDS * 1000; + private static readonly CLEANUP_PROBABILITY = 0.01; /** * 初始化 SessionTracker,自动清理旧格式数据 @@ -174,26 +179,26 @@ export class SessionTracker { try { const now = Date.now(); const pipeline = redis.pipeline(); + const ttlSeconds = SessionTracker.SESSION_TTL_SECONDS; + const providerZSetKey = `provider:${providerId}:active_sessions`; - // 更新所有相关 ZSET 的时间戳(滑动窗口) pipeline.zadd("global:active_sessions", now, sessionId); pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId); - pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId); + pipeline.zadd(providerZSetKey, now, sessionId); + // Use dynamic TTL based on session TTL (at least 1h to cover active sessions) + pipeline.expire(providerZSetKey, Math.max(3600, ttlSeconds)); if (userId !== undefined) { pipeline.zadd(`user:${userId}:active_sessions`, now, sessionId); } - // 修复 Bug:同步刷新 session 绑定信息的 TTL - // - // 问题:ZSET 条目(上面 zadd)会在每次请求时更新时间戳,但绑定信息 key 的 TTL 不会自动刷新 - // 导致:session 创建 5 分钟后,ZSET 仍有记录(仍被计为活跃),但绑定信息已过期,造成: - // 1. 并发检查被绕过(无法从绑定信息查询 session 所属 provider/key,检查失效) - // 2. Session 复用失败(无法确定 session 绑定关系,被迫创建新 session) - // - // 解决:每次 refreshSession 时同步刷新绑定信息 TTL(与 ZSET 保持 5 分钟生命周期一致) - pipeline.expire(`session:${sessionId}:provider`, 300); // 5 分钟(秒) - pipeline.expire(`session:${sessionId}:key`, 300); - pipeline.setex(`session:${sessionId}:last_seen`, 300, now.toString()); + pipeline.expire(`session:${sessionId}:provider`, ttlSeconds); + pipeline.expire(`session:${sessionId}:key`, ttlSeconds); + pipeline.setex(`session:${sessionId}:last_seen`, ttlSeconds, now.toString()); + + if (Math.random() < SessionTracker.CLEANUP_PROBABILITY) { + const cutoffMs = now - SessionTracker.SESSION_TTL_MS; + pipeline.zremrangebyscore(providerZSetKey, "-inf", cutoffMs); + } const results = await pipeline.exec(); @@ -374,14 +379,14 @@ export class SessionTracker { try { const now = Date.now(); - const fiveMinutesAgo = now - SessionTracker.SESSION_TTL; + const cutoffMs = now - SessionTracker.SESSION_TTL_MS; // 第一阶段:批量清理过期 session 并获取 session IDs const cleanupPipeline = redis.pipeline(); for (const providerId of providerIds) { const key = `provider:${providerId}:active_sessions`; // 清理过期 session - cleanupPipeline.zremrangebyscore(key, "-inf", fiveMinutesAgo); + cleanupPipeline.zremrangebyscore(key, "-inf", cutoffMs); // 获取剩余 session IDs cleanupPipeline.zrange(key, 0, -1); } @@ -480,10 +485,10 @@ export class SessionTracker { } const now = Date.now(); - const fiveMinutesAgo = now - SessionTracker.SESSION_TTL; + const cutoffMs = now - SessionTracker.SESSION_TTL_MS; // 清理过期 session - await redis.zremrangebyscore(key, "-inf", fiveMinutesAgo); + await redis.zremrangebyscore(key, "-inf", cutoffMs); // 获取剩余的 session ID return await redis.zrange(key, 0, -1); @@ -514,10 +519,10 @@ export class SessionTracker { try { const now = Date.now(); - const fiveMinutesAgo = now - SessionTracker.SESSION_TTL; + const cutoffMs = now - SessionTracker.SESSION_TTL_MS; // 1. 清理过期 session(5 分钟前) - await redis.zremrangebyscore(key, "-inf", fiveMinutesAgo); + await redis.zremrangebyscore(key, "-inf", cutoffMs); // 2. 获取剩余的 session ID const sessionIds = await redis.zrange(key, 0, -1); diff --git a/tests/unit/lib/rate-limit/service-extra.test.ts b/tests/unit/lib/rate-limit/service-extra.test.ts index 25118d5ba..0ef43dba6 100644 --- a/tests/unit/lib/rate-limit/service-extra.test.ts +++ b/tests/unit/lib/rate-limit/service-extra.test.ts @@ -164,6 +164,25 @@ describe("RateLimitService - other quota paths", () => { expect(result).toEqual({ allowed: true, count: 1, tracked: true }); }); + it("checkAndTrackProviderSession: should pass SESSION_TTL_MS as ARGV[4] to Lua script", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([1, 1, 1]); + await RateLimitService.checkAndTrackProviderSession(9, "sess", 2); + + // Verify eval was called with the correct args including ARGV[4] = SESSION_TTL_MS + expect(redisClientRef.eval).toHaveBeenCalledTimes(1); + + const evalCall = redisClientRef.eval.mock.calls[0]; + // evalCall: [script, numkeys, key, sessionId, limit, now, ttlMs] + // Indices: 0 1 2 3 4 5 6 + expect(evalCall.length).toBe(7); // script + 1 key + 5 ARGV + + // ARGV[4] (index 6) should be SESSION_TTL_MS derived from env (default 300s = 300000ms) + const ttlMsArg = evalCall[6]; + expect(ttlMsArg).toBe("300000"); + }); + it("trackUserDailyCost:fixed 模式应使用 STRING + TTL", async () => { const { RateLimitService } = await import("@/lib/rate-limit"); diff --git a/tests/unit/lib/session-tracker-cleanup.test.ts b/tests/unit/lib/session-tracker-cleanup.test.ts new file mode 100644 index 000000000..370e97614 --- /dev/null +++ b/tests/unit/lib/session-tracker-cleanup.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let redisClientRef: any; +const pipelineCalls: Array = []; + +const makePipeline = () => { + const pipeline = { + zadd: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zadd", ...args]); + return pipeline; + }), + expire: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["expire", ...args]); + return pipeline; + }), + setex: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["setex", ...args]); + return pipeline; + }), + zremrangebyscore: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zremrangebyscore", ...args]); + return pipeline; + }), + zrange: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zrange", ...args]); + return pipeline; + }), + exists: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["exists", ...args]); + return pipeline; + }), + exec: vi.fn(async () => { + pipelineCalls.push(["exec"]); + return []; + }), + }; + return pipeline; +}; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => redisClientRef, +})); + +describe("SessionTracker - TTL and cleanup", () => { + const nowMs = 1_700_000_000_000; + const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL; + + beforeEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + pipelineCalls.length = 0; + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + + redisClientRef = { + status: "ready", + exists: vi.fn(async () => 1), + type: vi.fn(async () => "zset"), + del: vi.fn(async () => 1), + zremrangebyscore: vi.fn(async () => 0), + zrange: vi.fn(async () => []), + pipeline: vi.fn(() => makePipeline()), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + if (ORIGINAL_SESSION_TTL === undefined) { + delete process.env.SESSION_TTL; + } else { + process.env.SESSION_TTL = ORIGINAL_SESSION_TTL; + } + }); + + describe("env-driven TTL", () => { + it("should use SESSION_TTL env (seconds) converted to ms for cutoff calculation", async () => { + // Set SESSION_TTL to 600 seconds (10 minutes) + process.env.SESSION_TTL = "600"; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + // Should call zremrangebyscore with cutoff = now - 600*1000 = now - 600000 + const expectedCutoff = nowMs - 600 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + + it("should default to 300 seconds (5 min) when SESSION_TTL not set", async () => { + delete process.env.SESSION_TTL; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + // Default: 300 seconds = 300000 ms + const expectedCutoff = nowMs - 300 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + }); + + describe("refreshSession - provider ZSET EXPIRE", () => { + it("should set EXPIRE on provider ZSET with fallback TTL 3600", async () => { + process.env.SESSION_TTL = "300"; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + // Check pipeline calls include expire for provider ZSET + const providerExpireCall = pipelineCalls.find( + (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions") + ); + expect(providerExpireCall).toBeDefined(); + expect(providerExpireCall![2]).toBe(3600); // fallback TTL + }); + + it("should use SESSION_TTL when it exceeds 3600s for provider ZSET EXPIRE", async () => { + process.env.SESSION_TTL = "7200"; // 2 hours > 3600 + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + // Check pipeline calls include expire for provider ZSET with dynamic TTL + const providerExpireCall = pipelineCalls.find( + (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions") + ); + expect(providerExpireCall).toBeDefined(); + expect(providerExpireCall![2]).toBe(7200); // should use SESSION_TTL when > 3600 + }); + + it("should refresh session binding TTLs using env SESSION_TTL (not hardcoded 300)", async () => { + process.env.SESSION_TTL = "600"; // 10 minutes + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + // Check expire calls for session bindings use 600 (env value), not 300 + const providerBindingExpire = pipelineCalls.find( + (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:provider" + ); + const keyBindingExpire = pipelineCalls.find( + (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:key" + ); + const lastSeenSetex = pipelineCalls.find( + (call) => call[0] === "setex" && String(call[1]) === "session:sess-123:last_seen" + ); + + expect(providerBindingExpire).toBeDefined(); + expect(providerBindingExpire![2]).toBe(600); + + expect(keyBindingExpire).toBeDefined(); + expect(keyBindingExpire![2]).toBe(600); + + expect(lastSeenSetex).toBeDefined(); + expect(lastSeenSetex![2]).toBe(600); + }); + }); + + describe("refreshSession - probabilistic cleanup on write path", () => { + it("should perform ZREMRANGEBYSCORE cleanup when probability gate hits", async () => { + process.env.SESSION_TTL = "300"; + + // Mock Math.random to always return 0 (below default 0.01 threshold) + vi.spyOn(Math, "random").mockReturnValue(0); + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + // Should have zremrangebyscore call for provider ZSET cleanup + const cleanupCall = pipelineCalls.find( + (call) => + call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions") + ); + expect(cleanupCall).toBeDefined(); + + // Cutoff should be now - SESSION_TTL_MS + const expectedCutoff = nowMs - 300 * 1000; + expect(cleanupCall![2]).toBe("-inf"); + expect(cleanupCall![3]).toBe(expectedCutoff); + }); + + it("should skip cleanup when probability gate does not hit", async () => { + process.env.SESSION_TTL = "300"; + + // Mock Math.random to return 0.5 (above default 0.01 threshold) + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + // Should NOT have zremrangebyscore call + const cleanupCall = pipelineCalls.find((call) => call[0] === "zremrangebyscore"); + expect(cleanupCall).toBeUndefined(); + }); + + it("should use env-driven TTL for cleanup cutoff calculation", async () => { + process.env.SESSION_TTL = "600"; // 10 minutes + + vi.spyOn(Math, "random").mockReturnValue(0); + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.refreshSession("sess-123", 1, 42); + + const cleanupCall = pipelineCalls.find( + (call) => + call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions") + ); + expect(cleanupCall).toBeDefined(); + + // Cutoff should be now - 600*1000 + const expectedCutoff = nowMs - 600 * 1000; + expect(cleanupCall![3]).toBe(expectedCutoff); + }); + }); + + describe("countFromZSet - env-driven TTL", () => { + it("should use env SESSION_TTL for cleanup cutoff in batch count", async () => { + process.env.SESSION_TTL = "600"; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + // getProviderSessionCountBatch uses SESSION_TTL internally + await SessionTracker.getProviderSessionCountBatch([1, 2]); + + // Check pipeline zremrangebyscore calls use correct cutoff + const cleanupCalls = pipelineCalls.filter((call) => call[0] === "zremrangebyscore"); + expect(cleanupCalls.length).toBeGreaterThan(0); + + const expectedCutoff = nowMs - 600 * 1000; + for (const call of cleanupCalls) { + expect(call[3]).toBe(expectedCutoff); + } + }); + }); + + describe("getActiveSessions - env-driven TTL", () => { + it("should use env SESSION_TTL for cleanup cutoff", async () => { + process.env.SESSION_TTL = "600"; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getActiveSessions(); + + const expectedCutoff = nowMs - 600 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + }); + + describe("Fail-Open behavior", () => { + it("refreshSession should not throw when Redis is not ready", async () => { + redisClientRef.status = "end"; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined(); + }); + + it("refreshSession should not throw when Redis is null", async () => { + redisClientRef = null; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/lib/session-ttl-validation.test.ts b/tests/unit/lib/session-ttl-validation.test.ts new file mode 100644 index 000000000..28fc13891 --- /dev/null +++ b/tests/unit/lib/session-ttl-validation.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for SESSION_TTL environment variable validation + * + * These tests verify that invalid SESSION_TTL values (NaN, 0, negative) + * are properly handled with fallback to default 300 seconds. + */ + +let redisClientRef: any; +const pipelineCalls: Array = []; + +const makePipeline = () => { + const pipeline = { + zadd: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zadd", ...args]); + return pipeline; + }), + expire: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["expire", ...args]); + return pipeline; + }), + setex: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["setex", ...args]); + return pipeline; + }), + zremrangebyscore: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zremrangebyscore", ...args]); + return pipeline; + }), + zrange: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zrange", ...args]); + return pipeline; + }), + exists: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["exists", ...args]); + return pipeline; + }), + exec: vi.fn(async () => { + pipelineCalls.push(["exec"]); + return []; + }), + }; + return pipeline; +}; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => redisClientRef, +})); + +describe("SESSION_TTL environment variable validation", () => { + const nowMs = 1_700_000_000_000; + const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL; + + beforeEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + pipelineCalls.length = 0; + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + + redisClientRef = { + status: "ready", + exists: vi.fn(async () => 1), + type: vi.fn(async () => "zset"), + del: vi.fn(async () => 1), + zremrangebyscore: vi.fn(async () => 0), + zrange: vi.fn(async () => []), + pipeline: vi.fn(() => makePipeline()), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + if (ORIGINAL_SESSION_TTL === undefined) { + delete process.env.SESSION_TTL; + } else { + process.env.SESSION_TTL = ORIGINAL_SESSION_TTL; + } + }); + + describe("SessionTracker TTL parsing", () => { + it("should use default 300 when SESSION_TTL is empty string", async () => { + process.env.SESSION_TTL = ""; + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + // Default: 300 seconds = 300000 ms + const expectedCutoff = nowMs - 300 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + + it("should use default 300 when SESSION_TTL is NaN", async () => { + process.env.SESSION_TTL = "not-a-number"; + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + const expectedCutoff = nowMs - 300 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + + it("should use default 300 when SESSION_TTL is 0", async () => { + process.env.SESSION_TTL = "0"; + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + const expectedCutoff = nowMs - 300 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + + it("should use default 300 when SESSION_TTL is negative", async () => { + process.env.SESSION_TTL = "-100"; + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + const expectedCutoff = nowMs - 300 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + + it("should use provided value when SESSION_TTL is valid positive integer", async () => { + process.env.SESSION_TTL = "600"; + const { SessionTracker } = await import("@/lib/session-tracker"); + + await SessionTracker.getGlobalSessionCount(); + + // Custom: 600 seconds = 600000 ms + const expectedCutoff = nowMs - 600 * 1000; + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + "global:active_sessions", + "-inf", + expectedCutoff + ); + }); + }); +});