import { jest } from "@jest/globals"; // Manual DB mock: getPool() returns a fake pooled client whose query() the test // drives. No real database, no injection seams — the module under test calls the // real getPool(), which Jest replaces here. const query = jest.fn(); const release = jest.fn(async () => {}); jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }), })); const { verifyKey, generateKey, listKeys, revokeKey, migrateLegacyApiKeys, _resetKeyCache } = await import("./apiKey.js"); beforeEach(() => { query.mockReset(); release.mockClear(); _resetKeyCache(); }); // Mint a key through generateKey and capture the stored (salted) hash, so the // verifyKey tests can round-trip a real scrypt hash without a DB. async function mintKey({ userId = 1, appId = 2 } = {}) { query .mockResolvedValueOnce({ rows: [{ id: appId }] }) // SELECT app .mockResolvedValueOnce({ rows: [{ id: 9, created_ts: "2026-01-01" }] }); // INSERT const out = await generateKey(userId, "core", { label: "ci" }); const insertParams = query.mock.calls[1][1]; return { raw: out.key, keyHash: insertParams[3], out }; } describe("generateKey", () => { it("stores a salted scrypt hash (not the raw key) and returns the raw key once", async () => { const { raw, keyHash, out } = await mintKey(); expect(raw).toMatch(/^[0-9a-f]{64}$/); expect(out).toMatchObject({ id: 9, prefix: raw.slice(0, 8), appType: "core", label: "ci" }); // Stored hash is ":" and never contains the raw key. expect(keyHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{128}$/); expect(keyHash).not.toContain(raw); expect(query.mock.calls[1][1]).not.toContain(raw); }); it("throws for an unknown app type", async () => { query.mockResolvedValueOnce({ rows: [] }); // app lookup empty await expect(generateKey(1, "nope", {})).rejects.toThrow("unknown app type"); }); }); describe("verifyKey", () => { it("returns null for an empty key without touching the DB", async () => { expect(await verifyKey("")).toBeNull(); expect(query).not.toHaveBeenCalled(); }); it("verifies a real key by prefix + scrypt, then serves the cache without a DB query", async () => { const { raw, keyHash } = await mintKey(); query.mockReset(); // First verify: prefix lookup returns the candidate, UPDATE last_used is best-effort. query .mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }) .mockResolvedValue({ rows: [] }); const first = await verifyKey(raw); expect(first).toEqual({ user_id: 1, app_id: 2 }); expect(query.mock.calls[0][1]).toEqual([raw.slice(0, 8)]); // looked up by prefix // Second verify: served from the 30-min cache — no new SELECT. query.mockClear(); const second = await verifyKey(raw); expect(second).toEqual({ user_id: 1, app_id: 2 }); const selects = query.mock.calls.filter((c) => /SELECT/.test(c[0])); expect(selects).toHaveLength(0); }); it("returns null when a candidate's hash does not match the raw key", async () => { const { keyHash } = await mintKey(); query.mockReset(); query.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }); expect(await verifyKey("some-other-raw-key-that-does-not-match")).toBeNull(); }); it("returns null when no candidate row matches the prefix", async () => { query.mockResolvedValueOnce({ rows: [] }); expect(await verifyKey("deadbeefcafefeed")).toBeNull(); }); }); describe("listKeys", () => { it("returns metadata rows for the user", async () => { const rows = [{ id: 1, appType: "core", prefix: "ab12cd34" }]; query.mockResolvedValueOnce({ rows }); expect(await listKeys(7)).toEqual(rows); expect(query.mock.calls[0][1]).toEqual([7]); }); }); describe("revokeKey", () => { it("deletes the user's key and evicts its cached validation", async () => { // Prime the cache by verifying a real key (id 9). const { raw, keyHash } = await mintKey(); query.mockReset(); query .mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] }) .mockResolvedValue({ rows: [] }); await verifyKey(raw); // Revoke id 9 -> deletes, and the cached entry is evicted so the next verify re-hits the DB. query.mockReset(); query.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 9 }] }); expect(await revokeKey(7, 9)).toBe(true); expect(query.mock.calls[0][1]).toEqual([9, 7]); // scoped to (id, user_id) query.mockReset(); query.mockResolvedValueOnce({ rows: [] }); // no candidate now expect(await verifyKey(raw)).toBeNull(); // cache was evicted -> DB consulted again expect(query.mock.calls[0][0]).toMatch(/SELECT/); }); it("returns false when nothing was deleted", async () => { query.mockResolvedValueOnce({ rowCount: 0, rows: [] }); expect(await revokeKey(7, 3)).toBe(false); }); }); describe("migrateLegacyApiKeys", () => { it("imports each legacy key once, hashed and with its prefix", async () => { query .mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth .mockResolvedValueOnce({ rowCount: 0, rows: [] }) // existence check -> not present .mockResolvedValueOnce({ rows: [] }); // INSERT expect(await migrateLegacyApiKeys()).toBe(1); const [sql, params] = query.mock.calls[2]; expect(sql).toMatch(/INSERT INTO api_key/); expect(params[4]).toBe("abcdef12"); // key_prefix = first 8 chars of the raw key expect(params[3]).toContain(":"); // salted scrypt hash ":" }); it("skips a key that was already imported (idempotent)", async () => { query .mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth .mockResolvedValueOnce({ rowCount: 1, rows: [{ ok: 1 }] }); // existence check -> present expect(await migrateLegacyApiKeys()).toBe(0); expect(query).toHaveBeenCalledTimes(2); // SELECT + existence check, no INSERT }); });