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

145 lines
6.0 KiB
JavaScript

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 "<saltHex>:<derivedHex>" 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 "<saltHex>:<derivedHex>"
});
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
});
});