import { jest } from "@jest/globals"; // The bound is read per call from config, so set it before the first verify // rather than before the import. Own file so the tiny bound cannot leak into // apiKey.test.js. process.env.DASHBOARD_KEY_CACHE_MAX = "2"; const query = jest.fn(); const release = jest.fn(async () => {}); jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }), })); const { loadConfig } = await import("../../common/config.js"); await loadConfig(); const { verifyKey, generateKey, _resetKeyCache } = await import("./apiKey.js"); beforeEach(() => { query.mockReset(); release.mockClear(); _resetKeyCache(); }); async function mintKey(id) { query.mockReset(); query .mockResolvedValueOnce({ rows: [{ id: 2 }] }) // SELECT app .mockResolvedValueOnce({ rows: [{ id, created_ts: "2026-01-01" }] }); // INSERT const out = await generateKey(1, "core", { label: `k${id}` }); return { raw: out.key, keyHash: query.mock.calls[1][1][3], id }; } async function verifyWithDb({ raw, keyHash, id }) { query.mockReset(); query .mockResolvedValueOnce({ rows: [{ id, user_id: 1, app_id: 2, key_hash: keyHash }] }) .mockResolvedValue({ rows: [] }); const result = await verifyKey(raw); return { result, selects: query.mock.calls.filter((c) => /SELECT/.test(c[0])) }; } async function servedFromCache({ raw }) { query.mockReset(); query.mockResolvedValue({ rows: [] }); // if it does hit the DB, it finds nothing const result = await verifyKey(raw); return result !== null && query.mock.calls.length === 0; } describe("validated-key cache bound", () => { it("evicts the least recently used key once the cache is full", async () => { const a = await mintKey(11); const b = await mintKey(12); const c = await mintKey(13); // Fill the cache to its bound of 2. await verifyWithDb(a); await verifyWithDb(b); // A hit counts as a use, so probes below check the EVICTED key first. expect(await servedFromCache(a)).toBe(true); await verifyWithDb(c); expect(await servedFromCache(b)).toBe(false); expect(await servedFromCache(a)).toBe(true); expect(await servedFromCache(c)).toBe(true); const { result, selects } = await verifyWithDb(b); expect(result).toEqual({ user_id: 1, app_id: 2 }); expect(selects).toHaveLength(1); }); it("never grows past the bound, however many distinct keys verify", async () => { const keys = []; for (let i = 0; i < 6; i++) keys.push(await mintKey(20 + i)); for (const k of keys) await verifyWithDb(k); const cached = []; for (const k of keys) if (await servedFromCache(k)) cached.push(k.id); expect(cached).toEqual([keys[4].id, keys[5].id]); }); it("a cache hit refreshes recency without extending the entry's TTL", async () => { const a = await mintKey(31); await verifyWithDb(a); const realNow = Date.now; try { // Must expire on its original deadline despite being touched. Date.now = () => realNow() + 31 * 60 * 1000; expect(await servedFromCache(a)).toBe(false); } finally { Date.now = realNow; } }); });