Files
jlinc-server/backend/modules/core/apiKey.cache.test.js
2026-08-20 15:08:32 +00:00

97 lines
3.1 KiB
JavaScript

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;
}
});
});