1import { describe, expect, it } from "vitest";23import {4 base64urlEncode,5 generatePasskeyKeypair,6} from "../background/passkey-crypto";7import { IMPORT_LIMITS } from "./import-limits";8import { detectImportFormat, parse1PasswordJSON, parseImportSource } from "./import";910describe("shared import parsing", () => {11 it("parses Bitwarden JSON login exports", async () => {12 const result = await parseImportSource(13 JSON.stringify({14 encrypted: false,15 items: [16 {17 type: 1,18 name: "Bitwarden Login",19 notes: "imported",20 login: {21 username: "[email protected]",22 password: "hunter2",23 uris: [{ uri: "https://vault.bitwarden.com" }],24 totp:25 "otpauth://totp/Bitwarden?secret=JBSWY3DPEHPK3PXP&issuer=Bitwarden",26 },27 },28 ],29 })30 );3132 expect(result.format).toBe("bitwarden-json");33 expect(result.entries).toHaveLength(1);34 expect(result.entries[0]).toMatchObject({35 title: "Bitwarden Login",36 username: "[email protected]",37 password: "hunter2",38 url: "https://vault.bitwarden.com",39 notes: "imported",40 totp: "JBSWY3DPEHPK3PXP",41 });42 });4344 it("parses Proton Pass CSV exports", async () => {45 const result = await parseImportSource(46 [47 "name,url,email,password,note,totp",48 '"Peach","https://peachpasswords.com","[email protected]","pass123","daily driver","otpauth://totp/Peach?secret=JBSWY3DPEHPK3PXP&issuer=Peach"',49 ].join("\n")50 );5152 expect(result.format).toBe("csv");53 expect(result.entries).toHaveLength(1);54 expect(result.entries[0]).toMatchObject({55 title: "Peach",56 username: "[email protected]",57 password: "pass123",58 url: "https://peachpasswords.com",59 notes: "daily driver",60 totp: "JBSWY3DPEHPK3PXP",61 });62 63 expect(result.entries[0].totpAlgorithm).toBeUndefined();64 expect(result.entries[0].totpDigits).toBeUndefined();65 expect(result.entries[0].totpPeriod).toBeUndefined();66 });6768 it("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => {69 70 const result = await parseImportSource(71 [72 "name,url,email,password,note,totp",73 '"A","https://a.test","[email protected]","pw","",""',74 '"B","https://b.test","[email protected]","pw","",""',75 '"C","https://c.test","[email protected]","pw","",""',76 '"D","https://d.test","[email protected]","pw","",""',77 '"E","https://e.test","[email protected]","","",""',78 ].join("\n")79 );80 expect(result.format).toBe("csv");81 expect(result.entries).toHaveLength(4);82 expect(result.totalItems).toBe(5);83 expect(result.skippedItems).toBe(1);84 });8586 it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {87 const result = await parseImportSource(88 [89 "name,url,email,password,note,totp",90 '"Acct","https://e.test","[email protected]","pw","","otpauth://totp/Acct?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Acct"',91 ].join("\n")92 );9394 expect(result.entries).toHaveLength(1);95 expect(result.entries[0]).toMatchObject({96 totp: "JBSWY3DPEHPK3PXP",97 totpAlgorithm: "SHA256",98 totpDigits: 8,99 totpPeriod: 60,100 });101 });102103 it("parses Proton Pass JSON exports", async () => {104 const result = await parseImportSource(105 JSON.stringify({106 version: "1.8.0",107 encrypted: false,108 vaults: {109 primary: {110 name: "Personal",111 items: [112 {113 data: {114 type: "login",115 metadata: {116 name: "Proton Login",117 note: "switching in",118 },119 content: {120 username: "peach-user",121 password: "pass123",122 urls: ["https://proton.me"],123 totpUri:124 "otpauth://totp/Proton?secret=JBSWY3DPEHPK3PXP&issuer=Proton",125 },126 },127 state: 1,128 },129 ],130 },131 },132 })133 );134135 expect(result.format).toBe("protonpass-json");136 expect(result.entries).toHaveLength(1);137 expect(result.entries[0]).toMatchObject({138 title: "Proton Login",139 username: "peach-user",140 password: "pass123",141 url: "https://proton.me",142 notes: "switching in",143 totp: "JBSWY3DPEHPK3PXP",144 });145 });146147 it("parses credential exchange passkey imports", async () => {148 const generated = await generatePasskeyKeypair(-7);149 const privateKey = await crypto.subtle.importKey(150 "jwk",151 generated.privateKeyJwk,152 { name: "ECDSA", namedCurve: "P-256" },153 true,154 ["sign"]155 );156 const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));157158 const result = await parseImportSource(159 JSON.stringify({160 version: { major: 1, minor: 0 },161 exporterRpId: "bitwarden.example",162 exporterDisplayName: "Bitwarden",163 timestamp: 1705228800,164 accounts: [165 {166 id: "account-1",167 username: "june",168 items: [169 {170 id: "item-1",171 title: "WebAuthn.io",172 credentials: [173 {174 type: "passkey",175 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),176 rpId: "webauthn.io",177 username: "june",178 userDisplayName: "June",179 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),180 key: base64urlEncode(pkcs8),181 },182 ],183 },184 ],185 },186 ],187 })188 );189190 expect(result.format).toBe("credential-exchange-json");191 expect(result.entries).toHaveLength(1);192 expect(result.entries[0]).toMatchObject({193 type: "login",194 title: "WebAuthn.io",195 username: "june",196 passkey: {197 rpId: "webauthn.io",198 userName: "june",199 userDisplayName: "June",200 signCount: 0,201 },202 });203 });204205 it("falls back gracefully on malformed JSON", async () => {206 const result = await parseImportSource("not valid json at all");207 expect(result.format).toBe("unknown");208 });209210 it("detectImportFormat identifies the supported JSON and CSV shapes", () => {211 expect(detectImportFormat('{"items":[{"type":1}]}')).toBe("bitwarden-json");212 expect(213 detectImportFormat(214 JSON.stringify({215 accounts: [{ vaults: [{ items: [{ overview: { title: "GitHub" } }] }] }],216 })217 )218 ).toBe("1password-json");219 expect(220 detectImportFormat(221 JSON.stringify({222 vaults: { primary: { items: [{ data: { type: "login" } }] } },223 })224 )225 ).toBe("protonpass-json");226 expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv");227 expect(detectImportFormat("totally unrecognized")).toBe("unknown");228 });229230 it("parseCSV maps LastPass-style grouping columns to tags", async () => {231 const result = await parseImportSource(232 [233 "name,url,username,password,grouping",234 '"GitHub","https://github.com","june","secret123","work/team"',235 ].join("\n")236 );237238 expect(result.entries).toMatchObject([239 {240 title: "GitHub",241 tags: ["work/team"],242 },243 ]);244 });245246 it("parse1PasswordJSON handles 1Password 8 data.items shape", () => {247 const entries = parse1PasswordJSON(248 JSON.stringify({249 items: [250 {251 title: "GitHub",252 url: "https://github.com",253 fields: [254 { id: "username", value: "june" },255 { id: "password", value: "secret123" },256 ],257 notes: "from 1password",258 },259 ],260 })261 );262263 expect(entries).toMatchObject([264 {265 title: "GitHub",266 username: "june",267 password: "secret123",268 url: "https://github.com",269 notes: "from 1password",270 },271 ]);272 });273274 it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {275 const huge = "x".repeat(11 * 1024 * 1024); 276 await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);277 });278});279280281282283284285describe("PB-26-606 import entry count cap", () => {286 const csvRows = (count: number) =>287 `name,password\n${Array.from({ length: count }, () => ",p").join("\n")}`;288289 it("accepts a CSV just under the cap", async () => {290 const result = await parseImportSource(csvRows(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT - 1));291 expect(result.entries).toHaveLength(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT - 1);292 });293294 it("rejects a CSV that exceeds the cap with IMPORT_TOO_LARGE", async () => {295 await expect(296 parseImportSource(csvRows(IMPORT_LIMITS.MAX_IMPORT_ENTRY_COUNT + 1)),297 ).rejects.toThrow("IMPORT_TOO_LARGE");298 });299});