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