import { describe, expect, it } from "vitest"; import { base64urlEncode, generatePasskeyKeypair, } from "../background/passkey-crypto"; import { exportEntriesToPeachJson, importEntriesFromPeachJson, type ImportableEntry, } from "./backup"; import { detectImportFormat, parse1PasswordJSON, parseImportSource } from "./import"; const SAMPLE_IMPORT_ENTRY: ImportableEntry = { title: "GitHub", username: "june", password: "secret123", url: "https://github.com", notes: "2fa enabled", totp: "otpauth://totp/Peach:GitHub?secret=JBSWY3DPEHPK3PXP&issuer=Peach", }; describe("shared import parsing", () => { it("parses Bitwarden JSON login exports", async () => { const result = await parseImportSource( JSON.stringify({ encrypted: false, items: [ { type: 1, name: "Bitwarden Login", notes: "imported", login: { username: "june@peach.test", password: "hunter2", uris: [{ uri: "https://vault.bitwarden.com" }], totp: "otpauth://totp/Bitwarden?secret=JBSWY3DPEHPK3PXP&issuer=Bitwarden", }, }, ], }) ); expect(result.format).toBe("bitwarden-json"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ title: "Bitwarden Login", username: "june@peach.test", password: "hunter2", url: "https://vault.bitwarden.com", notes: "imported", totp: "JBSWY3DPEHPK3PXP", }); }); it("parses Proton Pass CSV exports", async () => { const result = await parseImportSource( [ "name,url,email,password,note,totp", '"Peach","https://peachpasswords.com","hello@peach.test","pass123","daily driver","otpauth://totp/Peach?secret=JBSWY3DPEHPK3PXP&issuer=Peach"', ].join("\n") ); expect(result.format).toBe("csv"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ title: "Peach", username: "hello@peach.test", password: "pass123", url: "https://peachpasswords.com", notes: "daily driver", totp: "JBSWY3DPEHPK3PXP", }); // PCH-01-019: a default-param URI carries no sibling fields (stays legacy). expect(result.entries[0].totpAlgorithm).toBeUndefined(); expect(result.entries[0].totpDigits).toBeUndefined(); expect(result.entries[0].totpPeriod).toBeUndefined(); }); it("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => { // 5 data rows; one has no password and is skipped. const result = await parseImportSource( [ "name,url,email,password,note,totp", '"A","https://a.test","a@a.test","pw","",""', '"B","https://b.test","b@b.test","pw","",""', '"C","https://c.test","c@c.test","pw","",""', '"D","https://d.test","d@d.test","pw","",""', '"E","https://e.test","e@e.test","","",""', ].join("\n") ); expect(result.format).toBe("csv"); expect(result.entries).toHaveLength(4); expect(result.totalItems).toBe(5); expect(result.skippedItems).toBe(1); }); it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => { const result = await parseImportSource( [ "name,url,email,password,note,totp", '"Acct","https://e.test","u@e.test","pw","","otpauth://totp/Acct?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Acct"', ].join("\n") ); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ totp: "JBSWY3DPEHPK3PXP", totpAlgorithm: "SHA256", totpDigits: 8, totpPeriod: 60, }); }); it("parses Proton Pass JSON exports", async () => { const result = await parseImportSource( JSON.stringify({ version: "1.8.0", encrypted: false, vaults: { primary: { name: "Personal", items: [ { data: { type: "login", metadata: { name: "Proton Login", note: "switching in", }, content: { username: "peach-user", password: "pass123", urls: ["https://proton.me"], totpUri: "otpauth://totp/Proton?secret=JBSWY3DPEHPK3PXP&issuer=Proton", }, }, state: 1, }, ], }, }, }) ); expect(result.format).toBe("protonpass-json"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ title: "Proton Login", username: "peach-user", password: "pass123", url: "https://proton.me", notes: "switching in", totp: "JBSWY3DPEHPK3PXP", }); }); it("requires a password before decrypting Peach backups", async () => { const envelope = await exportEntriesToPeachJson([ { ...SAMPLE_IMPORT_ENTRY, id: "1", createdAt: 1, modifiedAt: 1, }, ], "export-pass"); const locked = await parseImportSource(JSON.stringify(envelope)); expect(locked.format).toBe("peach-json"); expect(locked.requiresPassword).toBe(true); const unlocked = await parseImportSource( JSON.stringify(envelope), "export-pass" ); expect(unlocked.entries).toHaveLength(1); expect(unlocked.entries[0]).toMatchObject({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); }); it("rejects the wrong password for Peach backups", async () => { const envelope = await exportEntriesToPeachJson([ { ...SAMPLE_IMPORT_ENTRY, id: "1", createdAt: 1, modifiedAt: 1, }, ], "export-pass"); await expect( importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass") ).rejects.toThrow("INVALID_EXPORT_PASSWORD"); }); it("restores passkeys from Peach encrypted backups", async () => { const generated = await generatePasskeyKeypair(-7); const envelope = await exportEntriesToPeachJson([ { id: "passkey-1", type: "passkey", title: "WebAuthn.io", rpId: "webauthn.io", rpName: "WebAuthn.io", credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 2, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: 1, modifiedAt: 1, }, ], "export-pass"); const { entries: restored } = await importEntriesFromPeachJson( JSON.stringify(envelope), "export-pass" ); expect(restored).toHaveLength(1); expect(restored[0]).toMatchObject({ type: "passkey", title: "WebAuthn.io", rpId: "webauthn.io", userName: "june", algorithm: -7, signCount: 2, }); }); it("parses credential exchange passkey imports", async () => { const generated = await generatePasskeyKeypair(-7); const privateKey = await crypto.subtle.importKey( "jwk", generated.privateKeyJwk, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"] ); const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey)); const result = await parseImportSource( JSON.stringify({ version: { major: 1, minor: 0 }, exporterRpId: "bitwarden.example", exporterDisplayName: "Bitwarden", timestamp: 1705228800, accounts: [ { id: "account-1", username: "june", items: [ { id: "item-1", title: "WebAuthn.io", credentials: [ { type: "passkey", credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), rpId: "webauthn.io", username: "june", userDisplayName: "June", userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), key: base64urlEncode(pkcs8), }, ], }, ], }, ], }) ); expect(result.format).toBe("credential-exchange-json"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ type: "passkey", title: "WebAuthn.io", rpId: "webauthn.io", userName: "june", userDisplayName: "June", signCount: 0, }); }); it("parses a valid peach-vault export and returns correct ImportableEntry[]", async () => { const result = await parseImportSource( JSON.stringify({ exportFormat: "peach-vault", exportFormatVersion: 2, exportedAt: "2025-01-15T10:00:00.000Z", exportedAtMs: 1736935200000, entryCount: 2, passkeyPrivateKeysWrapped: false, entries: [ { id: "550e8400-e29b-41d4-a716-446655440000", type: "login", modified: 1736935100000, created: 1736930000000, name: "GitHub", favorite: false, tags: [], login: { urls: ["https://github.com"], username: "june", password: "supersecret", totp: { secret: "BASE32SECRET", algorithm: "SHA1", digits: 6, period: 30, }, customFields: [], }, }, ], }) ); expect(result.format).toBe("peach-legacy-json"); expect(result.label).toBe("Peach (legacy)"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ title: "GitHub", url: "https://github.com", username: "june", password: "supersecret", totp: "BASE32SECRET", notes: undefined, }); expect(result.totalItems).toBe(1); expect(result.skippedItems).toBe(0); }); it("handles missing optional fields without throwing", async () => { const result = await parseImportSource( JSON.stringify({ exportFormat: "peach-vault", exportFormatVersion: 2, exportedAt: "2025-01-15T10:00:00.000Z", exportedAtMs: 1736935200000, entryCount: 1, passkeyPrivateKeysWrapped: false, entries: [ { id: "1", type: "login", modified: 1736935100000, created: 1736930000000, name: "Minimal Login", favorite: false, tags: [], login: { username: "june", password: "secret", }, }, ], }) ); expect(result.format).toBe("peach-legacy-json"); expect(result.entries).toHaveLength(1); expect(result.entries[0]).toMatchObject({ title: "Minimal Login", url: "", username: "june", password: "secret", totp: undefined, notes: undefined, }); }); it("falls back gracefully on malformed JSON", async () => { const result = await parseImportSource("not valid json at all"); expect(result.format).toBe("unknown"); }); it("detectImportFormat identifies the supported JSON and CSV shapes", () => { expect(detectImportFormat('{"items":[{"type":1}]}')).toBe("bitwarden-json"); expect( detectImportFormat( JSON.stringify({ accounts: [{ vaults: [{ items: [{ overview: { title: "GitHub" } }] }] }], }) ) ).toBe("1password-json"); expect( detectImportFormat( JSON.stringify({ vaults: { primary: { items: [{ data: { type: "login" } }] } }, }) ) ).toBe("protonpass-json"); expect( detectImportFormat( JSON.stringify({ type: "peach-export", version: 2, kdf: "PBKDF2-SHA256", salt: "00", nonce: "00", ciphertext: "00", tag: "00", }) ) ).toBe("peach-json"); expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv"); expect(detectImportFormat("totally unrecognized")).toBe("unknown"); }); it("parseCSV maps LastPass-style grouping columns to tags", async () => { const result = await parseImportSource( [ "name,url,username,password,grouping", '"GitHub","https://github.com","june","secret123","work/team"', ].join("\n") ); expect(result.entries).toMatchObject([ { title: "GitHub", tags: ["work/team"], }, ]); }); it("parse1PasswordJSON handles 1Password 8 data.items shape", () => { const entries = parse1PasswordJSON( JSON.stringify({ items: [ { title: "GitHub", url: "https://github.com", fields: [ { id: "username", value: "june" }, { id: "password", value: "secret123" }, ], notes: "from 1password", }, ], }) ); expect(entries).toMatchObject([ { title: "GitHub", username: "june", password: "secret123", url: "https://github.com", notes: "from 1password", }, ]); }); it("parseImportSource returns format 'peach-legacy-json' with correct entries", async () => { const result = await parseImportSource( JSON.stringify({ exportFormat: "peach-vault", exportFormatVersion: 2, exportedAt: "2025-01-15T10:00:00.000Z", exportedAtMs: 1736935200000, entryCount: 1, passkeyPrivateKeysWrapped: false, entries: [ { id: "1", type: "login", modified: 1736935100000, created: 1736930000000, name: "Test Login", favorite: false, tags: [], login: { urls: ["https://example.com"], username: "user", password: "pass123", totp: { secret: "TEST123", algorithm: "SHA1", digits: 6, period: 30 }, customFields: [], notes: "A note", }, }, ], }) ); expect(result.format).toBe("peach-legacy-json"); expect(result.entries).toHaveLength(1); expect(result.entries[0].title).toBe("Test Login"); expect(result.entries[0].username).toBe("user"); expect(result.entries[0].password).toBe("pass123"); expect(result.entries[0].url).toBe("https://example.com"); expect(result.entries[0].totp).toBe("TEST123"); expect(result.entries[0].notes).toBe("A note"); }); it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => { const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/); }); });