import { describe, expect, it, vi } from "vitest"; import { runApiKeyMigrationCli } from "./migrate-api-keys.js"; import type { ApiKeyMigrationCliDependencies } from "./migrate-api-keys.js"; function createOutputBuffer(): Pick & { text: () => string } { const chunks: string[] = []; return { write: vi.fn((chunk: string | Uint8Array) => { chunks.push(String(chunk)); return true; }) as unknown as typeof process.stdout.write, text: () => chunks.join(""), }; } function createDependencies(overrides: Partial = {}) { const stdout = createOutputBuffer(); const stderr = createOutputBuffer(); const backfillLegacyApiKeyHashes = vi.fn().mockResolvedValue({ scanned: 0, updated: 0, skipped: 0, failed: 0, failures: [] }); const dependencies = { stdout, stderr, backfillLegacyApiKeyHashes, ...overrides, }; return dependencies as typeof dependencies & { stdout: typeof stdout; stderr: typeof stderr; backfillLegacyApiKeyHashes: typeof backfillLegacyApiKeyHashes; }; } describe("runApiKeyMigrationCli", () => { it("defaults to dry-run and performs no writes", async () => { const dependencies = createDependencies(); await expect(runApiKeyMigrationCli([], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(0); expect(dependencies.backfillLegacyApiKeyHashes).not.toHaveBeenCalled(); expect(JSON.parse(dependencies.stdout.text())).toMatchObject({ mode: "dry-run", scanned: 0, updated: 0, skipped: 0, failed: 0, }); expect(dependencies.stdout.text()).not.toContain("fixture-hash-secret"); expect(dependencies.stderr.text()).toBe(""); }); it("executes the backfill with caller batch size and resolved hash secret", async () => { const dependencies = createDependencies({ backfillLegacyApiKeyHashes: vi.fn().mockResolvedValue({ scanned: 3, updated: 2, skipped: 1, failed: 0, failures: [], }), }); await expect( runApiKeyMigrationCli(["--execute", "--batch-size", "3"], { API_KEY_HASH_SECRET: "fixture-hash-secret", JWT_SECRET: "fallback-secret" }, dependencies), ).resolves.toBe(0); expect(dependencies.backfillLegacyApiKeyHashes).toHaveBeenCalledWith({ batchSize: 3, hashSecret: "fixture-hash-secret" }); expect(JSON.parse(dependencies.stdout.text())).toEqual({ mode: "execute", batchSize: 3, scanned: 3, updated: 2, skipped: 1, failed: 0, failureIds: [], }); expect(dependencies.stdout.text()).not.toContain("fixture-hash-secret"); expect(dependencies.stdout.text()).not.toContain("fallback-secret"); }); it("falls back to JWT_SECRET when API_KEY_HASH_SECRET is blank", async () => { const dependencies = createDependencies(); await expect(runApiKeyMigrationCli(["--execute"], { API_KEY_HASH_SECRET: " ", JWT_SECRET: "jwt-fallback-secret" }, dependencies)).resolves.toBe(0); expect(dependencies.backfillLegacyApiKeyHashes).toHaveBeenCalledWith({ batchSize: 100, hashSecret: "jwt-fallback-secret" }); expect(dependencies.stdout.text()).not.toContain("jwt-fallback-secret"); }); it("rejects invalid batch-size before DB writes", async () => { const dependencies = createDependencies(); await expect(runApiKeyMigrationCli(["--execute", "--batch-size", "0"], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(1); expect(dependencies.backfillLegacyApiKeyHashes).not.toHaveBeenCalled(); expect(dependencies.stderr.text()).toContain("Invalid --batch-size"); expect(dependencies.stderr.text()).not.toContain("fixture-hash-secret"); expect(dependencies.stdout.text()).toBe(""); }); it("redacts raw fixture secrets from failed summaries", async () => { const rawKey = "api_test_plaintext_secret"; const dependencies = createDependencies({ backfillLegacyApiKeyHashes: vi.fn().mockResolvedValue({ scanned: 1, updated: 0, skipped: 0, failed: 1, failures: [{ id: "api_key_1", error: `database leaked ${rawKey}` }], }), }); await expect(runApiKeyMigrationCli(["--execute"], { JWT_SECRET: "jwt-fallback-secret" }, dependencies)).resolves.toBe(1); expect(dependencies.stdout.text()).toContain("api_key_1"); expect(dependencies.stdout.text()).not.toContain(rawKey); expect(dependencies.stdout.text()).not.toContain("jwt-fallback-secret"); expect(dependencies.stderr.text()).toBe(""); }); it("redacts thrown migration helper errors", async () => { const rawKey = "api_test_plaintext_secret"; const dependencies = createDependencies({ backfillLegacyApiKeyHashes: vi.fn().mockRejectedValue(new Error(`connection failed for ${rawKey}`)), }); await expect(runApiKeyMigrationCli(["--execute"], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(1); expect(dependencies.stderr.text()).toContain("Error"); expect(dependencies.stderr.text()).not.toContain(rawKey); expect(dependencies.stderr.text()).not.toContain("fixture-hash-secret"); expect(dependencies.stdout.text()).toBe(""); }); });