import.test.ts

9.3 KB · raw

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    // PCH-01-019: a default-param URI carries no sibling fields (stays legacy).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    // 5 data rows; one has no password and is skipped.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); // 11 MiB > 10 MiB cap276    await expect(parseImportSource(huge)).rejects.toThrow(/IMPORT_TOO_LARGE/);277  });278});279280/**281 * PB-26-606 — a 10 MiB input of minimal rows expands to ~25x its size in282 * ImportableEntry objects, enough to exhaust an MV3 service worker. The cap283 * must trip while entries are still being accumulated.284 */285describe("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});