97 lines
3.1 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
});
|