145 lines
6.0 KiB
JavaScript
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
|
|
});
|
|
});
|