import { describe, it, expect, vi, beforeEach } from "vitest"; // Hoisted mocks for every Tauri / dataExport dependency. We purposely do // NOT talk to a real DB or FS — createPreMigrationBackup is a pure // orchestration layer over `invoke()` + dataExportService helpers. vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn(async () => "0.8.3-test"), })); vi.mock("./dataExportService", async () => { const actual = await vi.importActual( "./dataExportService", ); return { ...actual, getExportCategories: vi.fn(async () => []), getExportSuppliers: vi.fn(async () => []), getExportKeywords: vi.fn(async () => []), getExportTransactions: vi.fn(async () => []), }; }); import { invoke } from "@tauri-apps/api/core"; import { createPreMigrationBackup, sanitizeProfileName, filesystemSafeIsoTimestamp, buildBackupFilename, sha256Hex, BackupError, } from "./categoryBackupService"; import type { Profile } from "./profileService"; const mockInvoke = vi.mocked(invoke); // ----------------------------------------------------------------------------- // Plain-text profile (no PIN). // ----------------------------------------------------------------------------- const plainProfile: Profile = { id: "p1", name: "Max", color: "#f59e0b", pin_hash: null, db_filename: "max.db", created_at: "2026-01-01T00:00:00Z", }; const encryptedProfile: Profile = { ...plainProfile, id: "p2", pin_hash: "$argon2id$v=19$m=...", }; // ----------------------------------------------------------------------------- // Helpers — rebuild the "authoritative" SREF payload the service constructs // so we can feed read_import_file a consistent round-trip. // ----------------------------------------------------------------------------- function validEnvelope(): string { return JSON.stringify({ export_type: "transactions_with_categories", app_version: "0.8.3-test", exported_at: "2026-04-20T00:00:00Z", data: { categories: [], suppliers: [], keywords: [], transactions: [], }, }); } function setupHappyPathInvokes(payload: string): void { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") return undefined; if (cmd === "read_import_file") return payload; if (cmd === "get_file_size") return payload.length; throw new Error(`unexpected invoke: ${cmd}`); }); } beforeEach(() => { mockInvoke.mockReset(); }); // ----------------------------------------------------------------------------- // Pure helper coverage // ----------------------------------------------------------------------------- describe("sanitizeProfileName", () => { it("strips forbidden Windows characters", () => { expect(sanitizeProfileName('Bob/\\:*?"<>|Max')).toBe("BobMax"); }); it("collapses runs of whitespace into a single dash", () => { expect(sanitizeProfileName("Bob and Alice")).toBe("Bob-and-Alice"); }); it("falls back to 'profile' on empty/all-stripped input", () => { expect(sanitizeProfileName("")).toBe("profile"); expect(sanitizeProfileName(' ///\\ ')).toBe("profile"); }); it("caps length at 80 characters", () => { const long = "A".repeat(200); expect(sanitizeProfileName(long).length).toBe(80); }); }); describe("filesystemSafeIsoTimestamp", () => { it("replaces colons with dashes and drops fractional seconds", () => { const ts = filesystemSafeIsoTimestamp(new Date("2026-04-19T14:22:05.456Z")); expect(ts).toBe("2026-04-19T14-22-05Z"); }); }); describe("buildBackupFilename", () => { it("concatenates sanitized name + timestamp + .sref suffix", () => { const name = buildBackupFilename("Max", new Date("2026-04-19T14:22:05Z")); expect(name).toBe("Max_avant-migration-2026-04-19T14-22-05Z.sref"); }); it("sanitizes the profile name portion", () => { const name = buildBackupFilename("Bob:pro/file", new Date("2026-04-19T14:22:05Z")); expect(name.startsWith("Bobprofile_")).toBe(true); }); }); describe("sha256Hex", () => { it("matches the known SHA-256 of an empty string", async () => { const digest = await sha256Hex(""); expect(digest).toBe( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ); }); it("produces a stable 64-hex-chars digest for a short string", async () => { const digest = await sha256Hex("hello"); expect(digest).toMatch(/^[0-9a-f]{64}$/); expect(digest).toBe( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", ); }); }); // ----------------------------------------------------------------------------- // createPreMigrationBackup — success + failure modes // ----------------------------------------------------------------------------- describe("createPreMigrationBackup — happy path (no PIN)", () => { it("returns {path, size, checksum, encrypted=false} after round-trip", async () => { // The exact payload is constructed inside the service; we hand back the // same reference via read_import_file so the checksum matches. let captured: string | null = null; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { captured = (args as { content: string }).content; return undefined; } if (cmd === "read_import_file") return captured ?? ""; if (cmd === "get_file_size") return 4096; throw new Error(`unexpected invoke: ${cmd}`); }); const result = await createPreMigrationBackup({ profile: plainProfile }); expect(result.encrypted).toBe(false); expect(result.path.startsWith("/tmp/sr-backups")).toBe(true); expect(result.path.endsWith(".sref")).toBe(true); expect(result.size).toBe(4096); expect(result.checksum).toMatch(/^[0-9a-f]{64}$/); expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it("calls write_export_file with password=null for an unprotected profile", async () => { setupHappyPathInvokes(validEnvelope()); // Override write_export_file so we can inspect args. const writeArgs: unknown[] = []; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { writeArgs.push(args); return undefined; } if (cmd === "read_import_file") { // Re-serve the content that was just written so the checksum matches. const a = writeArgs[0] as { content: string }; return a.content; } if (cmd === "get_file_size") return 1; throw new Error(`unexpected invoke: ${cmd}`); }); await createPreMigrationBackup({ profile: plainProfile }); expect(writeArgs).toHaveLength(1); expect((writeArgs[0] as { password: string | null }).password).toBeNull(); }); }); describe("createPreMigrationBackup — encrypted profile (PIN)", () => { it("requires a password when the profile has a PIN hash", async () => { await expect( createPreMigrationBackup({ profile: encryptedProfile }), ).rejects.toThrow(BackupError); try { await createPreMigrationBackup({ profile: encryptedProfile }); } catch (e) { expect(e).toBeInstanceOf(BackupError); expect((e as BackupError).code).toBe("missing_password"); } }); it("forwards the trimmed password to write_export_file AND read_import_file", async () => { const writeArgs: Array<{ password: string | null }> = []; const readArgs: Array<{ password: string | null }> = []; let written = ""; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { const a = args as { content: string; password: string | null }; writeArgs.push({ password: a.password }); written = a.content; return undefined; } if (cmd === "read_import_file") { const a = args as { password: string | null }; readArgs.push({ password: a.password }); return written; } if (cmd === "get_file_size") return written.length; throw new Error(`unexpected invoke: ${cmd}`); }); const r = await createPreMigrationBackup({ profile: encryptedProfile, password: " 1234 ", }); expect(r.encrypted).toBe(true); expect(writeArgs[0].password).toBe("1234"); expect(readArgs[0].password).toBe("1234"); }); it("rejects when the password is whitespace-only", async () => { try { await createPreMigrationBackup({ profile: encryptedProfile, password: " ", }); throw new Error("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(BackupError); expect((e as BackupError).code).toBe("missing_password"); } }); }); describe("createPreMigrationBackup — write failures", () => { it("raises write_failed when invoke(write_export_file) rejects (generic)", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") throw new Error("write io error"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect(e).toBeInstanceOf(BackupError); expect((e as BackupError).code).toBe("write_failed"); } }); it("maps 'no space left' to the disk_space code", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") throw new Error("no space left on device"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("disk_space"); } }); it("maps 'permission denied' at write time to permission_denied", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") throw new Error("permission denied"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("permission_denied"); } }); }); describe("createPreMigrationBackup — dir failures", () => { it("maps ensure_backup_dir 'create_dir_failed' prefix to the matching code", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") throw new Error("create_dir_failed: EROFS"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("create_dir_failed"); } }); it("falls back to documents_dir_unavailable on unknown dir error", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") throw new Error("some weird io error"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("documents_dir_unavailable"); } }); }); describe("createPreMigrationBackup — integrity check", () => { it("raises verification_mismatch when the re-read content has a different checksum", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") return undefined; // Deliberately return a DIFFERENT, but still valid, envelope so the // parse check passes but the SHA-256 differs. if (cmd === "read_import_file") return JSON.stringify({ export_type: "transactions_with_categories", app_version: "0.0.0", exported_at: "2025-01-01T00:00:00Z", data: { categories: [{ id: 99 }] }, }); if (cmd === "get_file_size") return 123; throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("verification_mismatch"); expect((e as BackupError).detail).toContain("checksum_diff"); } }); it("raises verification_mismatch when the re-read content is not JSON", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") return undefined; if (cmd === "read_import_file") return "not{json"; if (cmd === "get_file_size") return 42; throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("verification_mismatch"); expect((e as BackupError).detail).toMatch(/envelope_parse/); } }); it("raises verification_mismatch when the envelope is the wrong export_type", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") return undefined; if (cmd === "read_import_file") return JSON.stringify({ export_type: "categories_only", app_version: "0.0.0", exported_at: "2025-01-01T00:00:00Z", data: { categories: [] }, }); if (cmd === "get_file_size") return 42; throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("verification_mismatch"); expect((e as BackupError).detail).toContain("envelope_type"); } }); it("raises read_back_failed when invoke(read_import_file) rejects", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") return undefined; if (cmd === "read_import_file") throw new Error("file gone"); throw new Error(`unexpected invoke: ${cmd}`); }); try { await createPreMigrationBackup({ profile: plainProfile }); throw new Error("should have thrown"); } catch (e) { expect((e as BackupError).code).toBe("read_back_failed"); } }); });