contracts.test.tsx

27.4 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24  sendMessage: (msg: unknown) => sendMessageMock(msg),25  triggerRing: () => triggerRingMock(),26  triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", () => ({34  faviconUrl: () => null,35}));3637vi.mock("../../crypto/totp", () => ({38  generateTOTP: () => ({ code: "123 456", remaining: 24 }),39  parseOtpauthURI: (value: string) =>40    value.startsWith("otpauth://")41      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }42      : null,43}));4445vi.mock("../components/GeneratorDrawer", () => ({46  GeneratorDrawer: () => null,47}));4849vi.mock("zxcvbn", () => ({50  default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54  const disposers: Array<() => void> = [];5556  beforeEach(() => {57    document.body.innerHTML = "";58    sendMessageMock.mockReset();59    triggerRingMock.mockReset();60    triggerPipPulseMock.mockReset();61    parseImportSourceMock.mockReset();62    zxcvbnMock.mockReset();63    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64    parseImportSourceMock.mockResolvedValue({65      format: "unknown",66      label: "Unknown format",67      entries: [],68      totalItems: 0,69      skippedItems: 0,70      requiresPassword: false,71    });7273    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74      return window.setTimeout(() => cb(0), 0) as unknown as number;75    });76    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77    vi.stubGlobal("URL", {78      createObjectURL: vi.fn(() => "blob:peach-test"),79      revokeObjectURL: vi.fn(),80    });81    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283    (globalThis as { chrome?: typeof chrome }).chrome = {84      runtime: {85        getManifest: () => ({ version: "1.4.47" }),86        onMessage: { addListener: vi.fn() },87      },88    } as unknown as typeof chrome;89  });9091  afterEach(() => {92    while (disposers.length > 0) {93      disposers.pop()?.();94    }95    vi.unstubAllGlobals();96  });9798  function mount(view: JSX.Element) {99    const mounted = renderView(view);100    disposers.push(mounted.dispose);101    return mounted.container;102  }103104  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105    return {106      id: "entry-1",107      type: "login",108      title: "GitHub",109      username: "june",110      password: "secret123",111      url: "https://github.com",112      totp: "JBSWY3DPEHPK3PXP",113      canary: {114        webhookUrl: "https://example.com/hook",115      },116      createdAt: 1,117      modifiedAt: 1,118      ...overrides,119    };120  }121122  function sampleImportResult(123    overrides: Partial<ImportParseResult> = {}124  ): ImportParseResult {125    return {126      format: "csv",127      label: "CSV import",128      entries: [129        {130          type: "login",131          title: "Imported site",132          username: "imported-user",133          password: "imported-pass",134          url: "https://imported.test",135        },136      ],137      totalItems: 1,138      skippedItems: 0,139      requiresPassword: false,140      ...overrides,141    };142  }143144  async function selectImportFile(145    container: HTMLElement,146    contents = '{"export":"test"}'147  ) {148    // The Import view now opens onto a menu — click into the file subview149    // before locating the file input. Idempotent: a second click won't re-fire150    // because the menu unmounts once the subview renders.151    const fileCard = findButton(container, "Choose file →");152    if (fileCard) {153      fileCard.click();154      await flushView();155    }156    const input = container.querySelector('input[type="file"]') as HTMLInputElement;157    const file = new File([contents], "import.json", { type: "application/json" });158    Object.defineProperty(input, "files", {159      configurable: true,160      value: [file],161    });162    input.dispatchEvent(new Event("change", { bubbles: true }));163    await flushView();164    await flushView();165  }166167  function findButton(container: HTMLElement, text: string): HTMLButtonElement {168    return Array.from(container.querySelectorAll("button")).find(169      (candidate) => candidate.textContent?.trim() === text170    ) as HTMLButtonElement;171  }172173  it("Lock sends UNLOCK with the entered passphrase", async () => {174    sendMessageMock.mockResolvedValue({ ok: true });175    const onUnlock = vi.fn(async () => undefined);176    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178    const password = container.querySelector("#unlock-password") as HTMLInputElement;179    inputText(password, "correct horse battery staple");180    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181    await flushView();182183    expect(sendMessageMock).toHaveBeenCalledWith({184      type: "UNLOCK",185      payload: { password: "correct horse battery staple" },186    });187    expect(onUnlock).toHaveBeenCalled();188  });189190  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193    const password = container.querySelector("#master-password") as HTMLInputElement;194    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196    inputText(password, "short");197    inputText(confirm, "short");198    await flushView();199    expect(createButton.disabled).toBe(true);200201    zxcvbnMock.mockImplementation(() => ({ score: 1 }));202    inputText(password, "weakweakweak");203    inputText(confirm, "weakweakweak");204    await flushView();205    expect(createButton.disabled).toBe(true);206  });207208  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock210    sendMessageMock.mockImplementation(211      async (msg: unknown) => {212        const m = msg as { type: string; payload?: { password?: string } };213        if (m.type === "CREATE_VAULT") return { ok: true };214        if (m.type === "GET_RECOVERY_WORDS") {215          // Return 12 fake words — must pass findInvalidWords216          return {217            ok: true,218            data: [219              "abandon", "ability", "able", "about", "above", "absent",220              "absorb", "abstract", "absurd", "abuse", "access", "accident",221            ],222          };223        }224        return { ok: true };225      }226    );227    zxcvbnMock.mockImplementation(() => ({ score: 3 }));228    const onUnlock = vi.fn(async () => undefined);229    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);230231    const password = container.querySelector("#master-password") as HTMLInputElement;232    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;233    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;234235    inputText(password, "correct horse battery staple");236    inputText(confirm, "correct horse battery staple");237    await flushView();238    expect(createButton.disabled).toBe(false);239240    pressEnter(confirm);241    await flushView();242243    expect(sendMessageMock).toHaveBeenCalledWith({244      type: "CREATE_VAULT",245      payload: { password: "correct horse battery staple" },246    });247    // After CREATE_VAULT success, the recovery word display is shown.248    // onUnlock is NOT called yet — user must first confirm their recovery words.249    expect(onUnlock).not.toHaveBeenCalled();250  });251252  it("Export menu surfaces both backup formats", async () => {253    const container = mount(<ExportView onBack={vi.fn()} />);254    await flushView();255256    expect(container.textContent).toContain("PeachScript");257    expect(container.textContent).toContain("CSV");258  });259260  it("Settings requires current, new, and confirm passwords before enabling save", async () => {261    sendMessageMock.mockResolvedValue({ ok: true, data: null });262    const container = mount(263      <SettingsView264        entriesCount={1}265        breachMonitoring={true}266        lockOnClose={true}267        autoLockMinutes={5}268        trashRetentionDays={30}269        theme="dark"270        accentColor="peach"271        displayFont="cormorant-italic"272        textSize="medium"273        reduceMotion={false}274        onBreachMonitoringChange={vi.fn()}275        onLockOnCloseChange={vi.fn()}276        onAutoLockMinutesChange={vi.fn()}277        onTrashRetentionChange={vi.fn()}278        onThemeChange={vi.fn()}279        onAccentChange={vi.fn()}280        onDisplayFontChange={vi.fn()}281        onTextSizeChange={vi.fn()}282        onReduceMotionChange={vi.fn()}283        shortcuts={DEFAULT_BINDINGS}284        onShortcutsChange={vi.fn()}285        onBack={vi.fn()}286        onImport={vi.fn()}287        onExport={vi.fn()}288        onBreachCheckDone={vi.fn()}289      />290    );291    await flushView();292293    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(294      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"295    ) as HTMLButtonElement;296    securityRow.click();297    await flushView();298299    const changeButton = Array.from(container.querySelectorAll("button")).find(300      (candidate) => candidate.textContent?.trim() === "change"301    ) as HTMLButtonElement;302    changeButton.click();303    await flushView();304305    const saveButton = Array.from(container.querySelectorAll("button")).find(306      (candidate) => candidate.textContent?.trim() === "save new password"307    ) as HTMLButtonElement;308    expect(saveButton.disabled).toBe(true);309310    const inputs = container.querySelectorAll('input[type="password"]');311    inputText(inputs[0] as HTMLInputElement, "current-pass");312    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");313    await flushView();314    expect(saveButton.disabled).toBe(true);315316    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");317    await flushView();318    expect(saveButton.disabled).toBe(false);319  });320321  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {322    sendMessageMock.mockImplementation(async (msg: unknown) => {323      const m = msg as { type: string };324      if (m.type === "GET_RECOVERY_WORDS") {325        return {326          ok: true,327          data: [328            "abandon", "ability", "able", "about", "above", "absent",329            "absorb", "abstract", "absurd", "abuse", "access", "accident",330          ],331        };332      }333      return { ok: true, data: null };334    });335    const onAutoLockMinutesChange = vi.fn();336    const onThemeChange = vi.fn();337    const container = mount(338      <SettingsView339        entriesCount={1}340        breachMonitoring={true}341        lockOnClose={true}342        autoLockMinutes={5}343        trashRetentionDays={30}344        theme="dark"345        accentColor="peach"346        displayFont="cormorant-italic"347        textSize="medium"348        reduceMotion={false}349        onBreachMonitoringChange={vi.fn()}350        onLockOnCloseChange={vi.fn()}351        onAutoLockMinutesChange={onAutoLockMinutesChange}352        onTrashRetentionChange={vi.fn()}353        onThemeChange={onThemeChange}354        onAccentChange={vi.fn()}355        onDisplayFontChange={vi.fn()}356        onTextSizeChange={vi.fn()}357        onReduceMotionChange={vi.fn()}358        shortcuts={DEFAULT_BINDINGS}359        onShortcutsChange={vi.fn()}360        onBack={vi.fn()}361        onImport={vi.fn()}362        onExport={vi.fn()}363        onBreachCheckDone={vi.fn()}364      />365    );366    await flushView();367    sendMessageMock.mockClear();368369    function headerBack() {370      return container.querySelector(".peach-overlay-header button") as HTMLButtonElement;371    }372373    // ── Auto-lock + theme changes (before password change) ─────────────374    const securityRow = () =>375      Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(376        (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"377      ) as HTMLButtonElement;378379    securityRow().click();380    await flushView();381382    const selects = container.querySelectorAll("select");383    changeSelect(selects[0] as HTMLSelectElement, "15");384    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);385386    headerBack().click();387    await flushView();388389    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(390      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"391    ) as HTMLButtonElement;392    appearanceRow.click();393    await flushView();394    const themeButton = Array.from(container.querySelectorAll("button")).find(395      (candidate) => candidate.textContent?.trim() === "Switch to light"396    ) as HTMLButtonElement;397    themeButton.click();398    expect(onThemeChange).toHaveBeenCalledWith("light");399400    // ── Password change → recovery words display ────────────────────────401    headerBack().click();402    await flushView();403404    securityRow().click();405    await flushView();406407    const changeButton = Array.from(container.querySelectorAll("button")).find(408      (candidate) => candidate.textContent?.trim() === "change"409    ) as HTMLButtonElement;410    changeButton.click();411    await flushView();412413    const inputs = container.querySelectorAll('input[type="password"]');414    inputText(inputs[0] as HTMLInputElement, "current-pass");415    inputText(inputs[1] as HTMLInputElement, "new-password-123");416    inputText(inputs[2] as HTMLInputElement, "new-password-123");417    await flushView();418419    const saveButton = Array.from(container.querySelectorAll("button")).find(420      (candidate) => candidate.textContent?.trim() === "save new password"421    ) as HTMLButtonElement;422    saveButton.click();423    await flushView();424425    expect(sendMessageMock).toHaveBeenCalledWith({426      type: "CHANGE_PASSWORD",427      payload: {428        currentPassword: "current-pass",429        newPassword: "new-password-123",430      },431    });432433    // After a successful password change the recovery word display replaces settings.434    expect(container.textContent).toContain("save your recovery words");435    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });436  });437438  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {439    sendMessageMock.mockResolvedValue({ ok: true });440    const container = mount(441      <EntryView442        entry={sampleEntry()}443        onDelete={vi.fn()}444        onFieldSaved={vi.fn(async () => undefined)}445      />446    );447448    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;449    passwordButton.click();450    await flushView();451452    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {453      type: "COPY",454      payload: { text: "secret123", label: "password", alreadyWritten: false },455    });456    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {457      type: "FIRE_CANARY_PING",458      payload: {459        webhookUrl: "https://example.com/hook",460        entryTitle: "GitHub",461        trigger: "password_copied",462      },463    });464465    sendMessageMock.mockClear();466    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;467    usernameButton.click();468    await flushView();469470    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {471      type: "COPY",472      payload: { text: "june", label: "username", alreadyWritten: false },473    });474    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {475      type: "FIRE_CANARY_PING",476      payload: {477        webhookUrl: "https://example.com/hook",478        entryTitle: "GitHub",479        trigger: "username_copied",480      },481    });482  });483484  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {485    sendMessageMock.mockResolvedValue({ ok: true });486    const onFieldSaved = vi.fn(async () => undefined);487    const container = mount(488      <EntryView489        entry={sampleEntry()}490        onDelete={vi.fn()}491        onFieldSaved={onFieldSaved}492      />493    );494495    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;496    passwordValue.click();497    await flushView();498499    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;500    inputText(passwordInput, "");501    await flushView();502503    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;504    saveButton.click();505    await flushView();506507    expect(sendMessageMock).not.toHaveBeenCalledWith(508      expect.objectContaining({ type: "UPDATE_ENTRY" })509    );510    expect(onFieldSaved).not.toHaveBeenCalled();511512    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;513    totpMain.click();514    await flushView();515516    expect(sendMessageMock).toHaveBeenLastCalledWith({517      type: "COPY",518      payload: { text: "123456", label: "totp", alreadyWritten: false },519    });520  });521522  it("Entry closes an unchanged inline editor when focus leaves it", async () => {523    sendMessageMock.mockResolvedValue({ ok: true });524    const container = mount(525      <EntryView526        entry={sampleEntry()}527        onDelete={vi.fn()}528        onFieldSaved={vi.fn(async () => undefined)}529      />530    );531532    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;533    usernameValue.click();534    await flushView();535536    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;537    usernameInput.blur();538    await flushView();539540    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();541    expect(sendMessageMock).not.toHaveBeenCalledWith(542      expect.objectContaining({ type: "UPDATE_ENTRY" })543    );544  });545546  it("Entry lets TOTP secrets be edited inline", async () => {547    sendMessageMock.mockResolvedValue({ ok: true });548    const onFieldSaved = vi.fn(async () => undefined);549    const container = mount(550      <EntryView551        entry={sampleEntry()}552        onDelete={vi.fn()}553        onFieldSaved={onFieldSaved}554      />555    );556557    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;558    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));559    await flushView();560561    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;562    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");563    await flushView();564565    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;566    saveButton.click();567    await flushView();568569    expect(sendMessageMock).toHaveBeenCalledWith({570      type: "UPDATE_ENTRY",571      payload: {572        id: "entry-1",573        patch: { totp: "NB2W45DFOIZA====" },574      },575    });576    expect(onFieldSaved).toHaveBeenCalled();577  });578579  it("Vault seeds and focuses search when a search seed is provided", async () => {580    const container = mount(581      <VaultView582        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}583        sortOrder="url-first"584        searchSeed="g"585        searchSeedNonce={1}586        onSelectEntry={vi.fn()}587        onSortOrderChange={vi.fn()}588        onAdd={vi.fn()}589        onClose={vi.fn()}590      />591    );592593    await flushView();594595    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;596    expect(search.value).toBe("g");597    expect(document.activeElement).toBe(search);598  });599600  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {601    parseImportSourceMock.mockResolvedValue(602      sampleImportResult({603        entries: [604          {605            type: "login",606            title: "Imported site",607            username: "imported-user",608            password: "imported-pass",609            url: "https://imported.test",610          },611        ],612      })613    );614    sendMessageMock.mockImplementation(async (msg: unknown) => {615      const type = (msg as { type?: string }).type;616      if (type === "GET_ENTRIES") {617        return {618          ok: true,619          data: [620            sampleEntry({621              id: "existing-1",622              title: "Imported site",623              username: "imported-user",624              url: "https://imported.test",625            }),626          ],627        };628      }629      if (type === "BATCH_ADD_ENTRIES") {630        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };631      }632      return { ok: true };633    });634    const onImport = vi.fn(async () => undefined);635    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);636    await flushView();637    sendMessageMock.mockClear();638639    await selectImportFile(container);640    findButton(container, "Import 1").click();641    await flushView();642643    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);644    expect(sendMessageMock).toHaveBeenCalledWith({645      type: "BATCH_ADD_ENTRIES",646      payload: {647        entries: [648          expect.objectContaining({649            title: "Imported site",650            username: "imported-user",651            password: "imported-pass",652            url: "https://imported.test",653          }),654        ],655      },656    });657    expect(onImport).toHaveBeenCalled();658    expect(container.textContent).toContain("1 entry appears to be duplicates");659  });660661  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {662    parseImportSourceMock.mockResolvedValue(sampleImportResult());663    sendMessageMock.mockImplementation(async (msg: unknown) => {664      const type = (msg as { type?: string }).type;665      if (type === "GET_ENTRIES") {666        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };667      }668      if (type === "CLEAR_VAULT_ENTRIES") {669        return { ok: true };670      }671      if (type === "BATCH_ADD_ENTRIES") {672        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };673      }674      return { ok: true };675    });676    const onImport = vi.fn(async () => undefined);677    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);678    await flushView();679    sendMessageMock.mockClear();680681    await selectImportFile(container);682    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();683    await flushView();684685    findButton(container, "Replace vault with 1").click();686    await flushView();687688    expect(sendMessageMock).not.toHaveBeenCalledWith(689      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })690    );691    expect(container.textContent).toContain("Replace forever? This cannot be undone.");692693    findButton(container, "Yes, replace forever").click();694    await flushView();695696    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });697    expect(sendMessageMock).toHaveBeenCalledWith({698      type: "BATCH_ADD_ENTRIES",699      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },700    });701    expect(onImport).toHaveBeenCalled();702  });703704  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {705    parseImportSourceMock.mockResolvedValue(sampleImportResult());706    sendMessageMock.mockImplementation(async (msg: unknown) => {707      const type = (msg as { type?: string }).type;708      if (type === "CREATE_VAULT") return { ok: true };709      if (type === "BATCH_ADD_ENTRIES") {710        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };711      }712      return { ok: true };713    });714    const onImport = vi.fn(async () => undefined);715    const container = mount(716      <ImportView717        mode="onboarding"718        replacesExistingVault={true}719        onBack={vi.fn()}720        onImport={onImport}721      />722    );723724    await selectImportFile(container);725726    const createButton = findButton(container, "Create vault + import 1");727    expect(createButton.disabled).toBe(true);728    expect(container.textContent).toContain("Restoring now replaces the locked local vault");729730    const masterInput = container.querySelector(731      'input[placeholder="new master password"]'732    ) as HTMLInputElement;733    const confirmInput = container.querySelector(734      'input[placeholder="confirm master password"]'735    ) as HTMLInputElement;736    inputText(masterInput, "fresh-master-password");737    inputText(confirmInput, "fresh-master-password");738    await flushView();739740    expect(createButton.disabled).toBe(false);741    createButton.click();742    await flushView();743744    expect(sendMessageMock).toHaveBeenCalledWith({745      type: "CREATE_VAULT",746      payload: { password: "fresh-master-password" },747    });748    expect(sendMessageMock).toHaveBeenCalledWith({749      type: "BATCH_ADD_ENTRIES",750      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },751    });752    expect(onImport).toHaveBeenCalled();753  });754755  it("Import encrypted backups reparse with the backup password before import", async () => {756    parseImportSourceMock757      .mockResolvedValueOnce(758        sampleImportResult({759          format: "peach-json",760          label: "Peach encrypted backup",761          entries: [],762          totalItems: 0,763          requiresPassword: true,764        })765      )766      .mockResolvedValueOnce(767        sampleImportResult({768          format: "peach-json",769          label: "Peach encrypted backup",770        })771      );772    sendMessageMock.mockImplementation(async (msg: unknown) => {773      const type = (msg as { type?: string }).type;774      if (type === "GET_ENTRIES") return { ok: true, data: [] };775      if (type === "BATCH_ADD_ENTRIES") {776        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };777      }778      return { ok: true };779    });780    const onImport = vi.fn(async () => undefined);781    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);782    await flushView();783    sendMessageMock.mockClear();784785    await selectImportFile(container, '{"type":"peach-export"}');786    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;787    inputText(password, "backup-passphrase");788    pressEnter(password);789    await flushView();790    await flushView();791792    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);793    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");794795    findButton(container, "Import 1").click();796    await flushView();797798    expect(sendMessageMock).toHaveBeenCalledWith({799      type: "BATCH_ADD_ENTRIES",800      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },801    });802    expect(onImport).toHaveBeenCalled();803  });804});