import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createTestDb, openRawDb } from "@parking/db/testing"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_BACKUP_RETENTION, parseBackupStamp, pruneOldBackups, runBackup, } from "./backup.js"; // Mirror of the engine's header layout, so the test decrypts independently (a real restore // tool would do exactly this) rather than trusting the engine to also decrypt. const MAGIC = Buffer.from("PKBK", "ascii"); const SALT_LEN = 16; const IV_LEN = 12; const TAG_LEN = 16; function decryptBackup(enc: Buffer, key: string): Buffer { expect(enc.subarray(0, 4)).toEqual(MAGIC); expect(enc[4]).toBe(1); // format version let off = 5; const salt = enc.subarray(off, (off += SALT_LEN)); const iv = enc.subarray(off, (off += IV_LEN)); const tag = enc.subarray(enc.length - TAG_LEN); const ciphertext = enc.subarray(off, enc.length - TAG_LEN); const derived = scryptSync(key, salt, 32); const decipher = createDecipheriv("aes-256-gcm", derived, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]); } let workDir: string; const KEY = "a-test-backup-key-that-is-long-enough"; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-")); }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); }); describe("runBackup — round-trip", () => { it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => { // A real on-disk DB so the engine's better-sqlite3 .backup() runs for real. const dbPath = join(workDir, "source.sqlite"); const t = createTestDb(dbPath); // Put some recognizable data in. t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)"); t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world"); const targetDir = join(workDir, "target"); const res = await runBackup(t.db, { targetDir, key: KEY }); t.close(); expect(res.bytes).toBeGreaterThan(0); expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/); // Decrypt independently and open the recovered DB raw (no migrations — verify as-written). const plain = decryptBackup(readFileSync(res.path), KEY); const restoredPath = join(workDir, "restored.sqlite"); writeFileSync(restoredPath, plain); const restored = openRawDb(restoredPath); const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string }; expect(row.v).toBe("world"); restored.close(); }); it("rejects a missing/short key before touching the filesystem", async () => { const t = createTestDb(); await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow( /BACKUP_KEY/, ); t.close(); }); it("removes the plaintext scratch copy after a successful run", async () => { const scratchDir = join(workDir, "scratch"); const t = createTestDb(); await runBackup(t.db, { targetDir: join(workDir, "target"), key: KEY, scratchDir, // Stub the copy so we don't need a file-backed handle here. makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"), }); t.close(); // The only thing left in scratch must NOT be a .sqlite plaintext. const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite")); expect(left).toEqual([]); }); it("wipes the plaintext scratch copy even when the copy step fails", async () => { const scratchDir = join(workDir, "scratch"); mkdirSync(scratchDir, { recursive: true }); const t = createTestDb(); // Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The // finally{} must still remove the plaintext it left behind. await expect( runBackup(t.db, { targetDir: join(workDir, "target"), key: KEY, scratchDir, makeConsistentCopy: async (_db, dest) => { writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate… throw new Error("simulated copy failure"); // …then fail }, }), ).rejects.toThrow(/simulated copy failure/); t.close(); const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite")); expect(left).toEqual([]); }); }); describe("backup encryption — tamper evidence (AES-256-GCM)", () => { it("a flipped ciphertext byte fails authentication on decrypt", async () => { const t = createTestDb(); const targetDir = join(workDir, "target"); const res = await runBackup(t.db, { targetDir, key: KEY, makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)), }); t.close(); const enc = readFileSync(res.path); // Flip a byte in the ciphertext region (after the header, before the tag). enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff; expect(() => decryptBackup(enc, KEY)).toThrow(); }); it("the wrong key fails authentication", async () => { const t = createTestDb(); const res = await runBackup(t.db, { targetDir: join(workDir, "target"), key: KEY, makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)), }); t.close(); expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow(); }); }); describe("parseBackupStamp", () => { it("round-trips a stamped name and rejects non-backups", () => { const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc"); expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z"); expect(parseBackupStamp("random.txt")).toBeNull(); expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull(); }); }); describe("pruneOldBackups — keep-last-N + dailies", () => { const day = 24 * 60 * 60 * 1000; const now = new Date("2026-06-29T12:00:00Z"); function seed(stamps: string[]) { const dir = join(workDir, "retain"); mkdirSync(dir, { recursive: true }); for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x"); return dir; } const stamp = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); it("keeps the keepLast newest regardless of age", async () => { // 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent. const t = now.getTime(); const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000))); const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now); expect(pruned).toBe(2); expect(readdirSync(dir).length).toBe(3); }); it("keeps one-per-day within the daily window and drops older", async () => { const t = now.getTime(); // Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30. const dir = seed([ stamp(t), // today A (newest → kept by keepLast) stamp(t - 60 * 1000), // today B (same day as the kept one → pruned) stamp(t - 5 * day), // 5 days ago (kept: within window, unique day) stamp(t - 40 * day), // 40 days ago (pruned: outside the window) ]); const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now); expect(pruned).toBe(2); const left = readdirSync(dir); expect(left.length).toBe(2); }); it("is a no-op on a missing target dir", async () => { const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now); expect(pruned).toBe(0); }); });