contracts.test.tsx

50.2 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 { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26  sendMessage: (msg: unknown) => sendMessageMock(msg),27  triggerRing: () => triggerRingMock(),28  triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36  const actual = await importOriginal<typeof import("../../shared/favicon")>();37  return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41  generateTOTP: () => ({ code: "123 456", remaining: 24 }),42  parseOtpauthURI: (value: string) =>43    value.startsWith("otpauth://")44      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45      : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49  GeneratorDrawer: () => null,50}));5152vi.mock("../components/RuneBackground", () => ({53  RuneBackground: () => null,54}));5556vi.mock("@zxcvbn-ts/core", () => ({57  zxcvbn: (value: string) => zxcvbnMock(value),58  zxcvbnOptions: { setOptions: vi.fn() },59}));60vi.mock("@zxcvbn-ts/language-common", () => ({61  adjacencyGraphs: {},62  dictionary: {},63}));64vi.mock("@zxcvbn-ts/language-en", () => ({65  translations: {},66  dictionary: {},67}));6869describe("popup contract tests", () => {70  const disposers: Array<() => void> = [];7172  beforeEach(() => {73    document.body.innerHTML = "";74    sendMessageMock.mockReset();75    triggerRingMock.mockReset();76    triggerPipPulseMock.mockReset();77    parseImportSourceMock.mockReset();78    zxcvbnMock.mockReset();79    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));80    parseImportSourceMock.mockResolvedValue({81      format: "unknown",82      label: "Unknown format",83      entries: [],84      totalItems: 0,85      skippedItems: 0,86      requiresPassword: false,87    });8889    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {90      return window.setTimeout(() => cb(0), 0) as unknown as number;91    });92    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));93    vi.stubGlobal("URL", {94      createObjectURL: vi.fn(() => "blob:peach-test"),95      revokeObjectURL: vi.fn(),96    });97    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9899    (globalThis as { chrome?: typeof chrome }).chrome = {100      runtime: {101        getManifest: () => ({ version: "1.4.47" }),102        onMessage: { addListener: vi.fn() },103      },104    } as unknown as typeof chrome;105  });106107  afterEach(() => {108    while (disposers.length > 0) {109      disposers.pop()?.();110    }111    vi.restoreAllMocks();112    vi.unstubAllGlobals();113  });114115  function mount(view: JSX.Element | (() => JSX.Element)) {116    const mounted = renderView(view);117    disposers.push(mounted.dispose);118    return mounted.container;119  }120121  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {122    return {123      id: "entry-1",124      type: "login",125      title: "GitHub",126      username: "june",127      password: "secret123",128      url: "https://github.com",129      totp: "JBSWY3DPEHPK3PXP",130      canary: {131        webhookUrl: "https://example.com/hook",132      },133      createdAt: 1,134      modifiedAt: 1,135      ...overrides,136    };137  }138139  function sampleImportResult(140    overrides: Partial<ImportParseResult> = {}141  ): ImportParseResult {142    return {143      format: "csv",144      label: "CSV import",145      entries: [146        {147          type: "login",148          title: "Imported site",149          username: "imported-user",150          password: "imported-pass",151          url: "https://imported.test",152        },153      ],154      totalItems: 1,155      skippedItems: 0,156      requiresPassword: false,157      ...overrides,158    };159  }160161  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {162    return {163      entriesCount: 1,164      breachMonitoring: true,165      lockOnClose: true,166      autoLockMinutes: 5,167      trashRetentionDays: 30,168      theme: "dark" as const,169      accentColor: "peach" as const,170      textSize: "medium" as const,171      reduceMotion: false,172      onBreachMonitoringChange: vi.fn(),173      onLockOnCloseChange: vi.fn(),174      onAutoLockMinutesChange: vi.fn(),175      onTrashRetentionChange: vi.fn(),176      onThemeChange: vi.fn(),177      onAccentChange: vi.fn(),178      onTextSizeChange: vi.fn(),179      onReduceMotionChange: vi.fn(),180      shortcuts: DEFAULT_BINDINGS,181      onShortcutsChange: vi.fn(),182      onBack: vi.fn(),183      onImport: vi.fn(),184      onExport: vi.fn(),185      onBreachCheckDone: vi.fn(),186      ...overrides,187    };188  }189190  async function selectImportFile(191    container: HTMLElement,192    contents = '{"export":"test"}'193  ) {194    // The Import view now opens onto a menu — click into the file subview195    // before locating the file input. Idempotent: a second click won't re-fire196    // because the menu unmounts once the subview renders.197    const fileCard = findButton(container, "Choose file →");198    if (fileCard) {199      fileCard.click();200      await flushView();201    }202    const input = container.querySelector('input[type="file"]') as HTMLInputElement;203    const file = new File([contents], "import.json", { type: "application/json" });204    Object.defineProperty(input, "files", {205      configurable: true,206      value: [file],207    });208    input.dispatchEvent(new Event("change", { bubbles: true }));209    await flushView();210    await flushView();211  }212213  function findButton(container: HTMLElement, text: string): HTMLButtonElement {214    return Array.from(container.querySelectorAll("button")).find(215      (candidate) => candidate.textContent?.trim() === text216    ) as HTMLButtonElement;217  }218219  it("Lock sends UNLOCK with the entered passphrase", async () => {220    sendMessageMock.mockResolvedValue({ ok: true });221    const onUnlock = vi.fn(async () => undefined);222    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);223224    const password = container.querySelector("#unlock-password") as HTMLInputElement;225    inputText(password, "correct horse battery staple");226    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();227    await flushView();228229    expect(sendMessageMock).toHaveBeenCalledWith({230      type: "UNLOCK",231      payload: { password: "correct horse battery staple" },232    });233    expect(onUnlock).toHaveBeenCalled();234  });235236  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {237    sendMessageMock.mockImplementation(async (msg: unknown) => {238      return (msg as { type: string }).type === "GET_SETTINGS"239        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }240        : { ok: true };241    });242    const onUnlock = vi.fn(async () => undefined);243    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);244    await flushView();245246    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");247    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();248    await flushView();249250    expect(sendMessageMock).toHaveBeenCalledWith({251      type: "UNLOCK_WITH_PIN",252      payload: { pin: "1234" },253    });254    expect(onUnlock).toHaveBeenCalled();255    expect(container.textContent).toContain("use master password");256  });257258  it("places the PIN method switch below the unlock button", async () => {259    sendMessageMock.mockImplementation(async (msg: unknown) => {260      return (msg as { type: string }).type === "GET_SETTINGS"261        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }262        : { ok: true };263    });264    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);265    await flushView();266267    const buttons = Array.from(container.querySelectorAll("button"));268    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");269    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");270    expect(pinIndex).toBeGreaterThan(unlockIndex);271  });272273  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {274    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);275    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276    const password = container.querySelector("#master-password") as HTMLInputElement;277    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;278279    inputText(password, "short");280    inputText(confirm, "short");281    await flushView();282    expect(createButton.disabled).toBe(true);283284    zxcvbnMock.mockImplementation(() => ({ score: 1 }));285    inputText(password, "weakweakweak");286    inputText(confirm, "weakweakweak");287    await flushView();288    expect(createButton.disabled).toBe(true);289  });290291  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {292    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock293    sendMessageMock.mockImplementation(294      async (msg: unknown) => {295        const m = msg as { type: string; payload?: { password?: string } };296        if (m.type === "CREATE_VAULT") return { ok: true };297        if (m.type === "GET_RECOVERY_WORDS") {298          // Return 12 fake words — must pass findInvalidWords299          return {300            ok: true,301            data: [302              "abandon", "ability", "able", "about", "above", "absent",303              "absorb", "abstract", "absurd", "abuse", "access", "accident",304            ],305          };306        }307        return { ok: true };308      }309    );310    zxcvbnMock.mockImplementation(() => ({ score: 3 }));311    const onUnlock = vi.fn(async () => undefined);312    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);313314    const password = container.querySelector("#master-password") as HTMLInputElement;315    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;316    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;317318    inputText(password, "correct horse battery staple");319    inputText(confirm, "correct horse battery staple");320    await flushView();321    expect(createButton.disabled).toBe(false);322323    pressEnter(confirm);324    await flushView();325326    expect(sendMessageMock).toHaveBeenCalledWith({327      type: "CREATE_VAULT",328      payload: { password: "correct horse battery staple" },329    });330    // After CREATE_VAULT success, the recovery word display is shown.331    // onUnlock is NOT called yet — user must first confirm their recovery words.332    expect(onUnlock).not.toHaveBeenCalled();333  });334335  it("Recovery words flow submits the new master password and completes", async () => {336    sendMessageMock.mockImplementation(async (msg: unknown) => {337      const type = (msg as { type?: string }).type;338      if (type === "VALIDATE_RECOVERY_WORDS") {339        return { ok: true, data: { valid: true, invalid: [] } };340      }341      if (type === "RECOVER_WITH_WORDS") return { ok: true };342      return { ok: true };343    });344    const canvasContext = {345      clearRect: vi.fn(),346      fillText: vi.fn(),347      beginPath: vi.fn(),348      closePath: vi.fn(),349      arc: vi.fn(),350      fill: vi.fn(),351      stroke: vi.fn(),352      save: vi.fn(),353      restore: vi.fn(),354      translate: vi.fn(),355      rotate: vi.fn(),356      scale: vi.fn(),357      drawImage: vi.fn(),358      measureText: vi.fn(() => ({ width: 0 })),359      setTransform: vi.fn(),360      fillRect: vi.fn(),361      strokeRect: vi.fn(),362    } as unknown as CanvasRenderingContext2D;363    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);364365    const onRecoveryComplete = vi.fn(async () => undefined);366    const container = mount(367      <RecoveryWordEntryView368        onCancel={vi.fn()}369        onRecoveryComplete={onRecoveryComplete}370      />371    );372373    const recoveryWords = WORD_LIST.slice(0, 12);374    for (let i = 0; i < recoveryWords.length; i++) {375      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;376      inputText(input, recoveryWords[i]!);377    }378    await flushView();379380    findButton(container, "verify words").click();381    await flushView();382383    expect(container.textContent).toContain("new master password");384385    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;386    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;387    inputText(newPassword, "fresh-recovery-password");388    inputText(confirmPassword, "fresh-recovery-password");389    await flushView();390391    findButton(container, "recover vault").click();392    await flushView();393394    expect(sendMessageMock).toHaveBeenCalledWith({395      type: "RECOVER_WITH_WORDS",396      payload: {397        words: recoveryWords,398        newPassword: "fresh-recovery-password",399      },400    });401    expect(onRecoveryComplete).toHaveBeenCalled();402  });403404  it("Export menu surfaces both backup formats", async () => {405    const container = mount(<ExportView onBack={vi.fn()} />);406    await flushView();407408    expect(container.textContent).toContain("PeachScript");409    expect(container.textContent).toContain("CSV");410  });411412  it("Lock can open directly on backup restore from onboarding", async () => {413    const container = mount(() => (414      <LockView415        hasVault={false}416        ready={true}417        onUnlock={vi.fn()}418        initialRestore={true}419      />420    ));421    await flushView();422423    expect(container.textContent).toContain("PeachScript");424    expect(container.textContent).toContain("Codex");425    expect(container.textContent).toContain("CSV");426    expect(container.querySelector("#master-password")).toBeNull();427  });428429  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {430    const device = {431      deviceId: "phone-1",432      deviceName: "June's Phone",433      deviceType: "android" as const,434      longTermPublicKeyHex: "a".repeat(64),435      signingPublicKeyHex: "b".repeat(64),436      pairedAt: 1,437      trustSource: "direct" as const,438    };439    sendMessageMock.mockImplementation(async (msg: unknown) => {440      const m = msg as { type: string };441      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };442      return { ok: true, data: null };443    });444445    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);446    for (let i = 0; i < 30; i++) await Promise.resolve();447448    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>449      /sync/i.test(b.textContent ?? ""),450    );451    openSync?.click();452    for (let i = 0; i < 30; i++) await Promise.resolve();453454    const remove = Array.from(container.querySelectorAll("button")).find(455      (b) => (b.textContent ?? "").trim() === "remove",456    );457    expect(remove).toBeTruthy();458459    // Guard against a vacuous pass: the disclosure must not already be on460    // screen before the user asks to remove anything.461    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();462463    remove!.click();464    for (let i = 0; i < 10; i++) await Promise.resolve();465466    // Revocation must never be presented as securing the vault against the467    // removed device — recovery words survive it and restore everything.468    const note = container.querySelector(".peach-unpair-confirm-note");469    expect(note).toBeTruthy();470    const text = note!.textContent ?? "";471    expect(text).toContain("master password");472    expect(text).toContain("recovery words");473    expect(text).toContain("future changes");474  });475476  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {477    const device = {478      deviceId: "phone-1",479      deviceName: "June's Phone",480      deviceType: "android" as const,481      longTermPublicKeyHex: "a".repeat(64),482      signingPublicKeyHex: "b".repeat(64),483      pairedAt: 1,484      trustSource: "direct" as const,485    };486    let paired: typeof device[] = [device];487    sendMessageMock.mockImplementation(async (msg: any) => {488      if (msg?.type === "LIST_PAIRED_DEVICES") {489        return { ok: true, data: { devices: [...paired] } };490      }491      if (msg?.type === "UNPAIR_DEVICE") {492        // Mirror the real handler: UNPAIR_DEVICE drops the row from the493        // registry. The next LIST_PAIRED_DEVICES must not see it.494        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);495        return { ok: true };496      }497      if (msg?.type === "RENAME_PAIRED_DEVICE") {498        paired = paired.map((d) =>499          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,500        );501        return { ok: true };502      }503      return { ok: true, data: null };504    });505506    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);507    for (let i = 0; i < 30; i++) await Promise.resolve();508    Array.from(container.querySelectorAll("button"))509      .find((b) => /sync/i.test(b.textContent ?? ""))510      ?.click();511    for (let i = 0; i < 30; i++) await Promise.resolve();512513    // The list itself: extracting the relay selector must not take the hub with it.514    expect(container.textContent).toContain("PAIRED DEVICES");515    expect(container.textContent).toContain("June's Phone");516    expect(container.textContent).toContain("Android");517    Array.from(container.querySelectorAll("button"))518      .find((b) => (b.textContent ?? "").trim() === "rename")!519      .click();520    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;521    renameInput.value = "Travel phone";522    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));523    Array.from(container.querySelectorAll("button"))524      .find((b) => (b.textContent ?? "").trim() === "save")!525      .click();526    for (let i = 0; i < 30; i++) await Promise.resolve();527    expect(sendMessageMock).toHaveBeenCalledWith({528      type: "RENAME_PAIRED_DEVICE",529      payload: { deviceId: "phone-1", name: "Travel phone" },530    });531    expect(container.textContent).toContain("Travel phone");532    const syncNowButton = container.querySelector<HTMLButtonElement>(533      'button[aria-label="Sync this vault now"]',534    )!;535    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));536    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;537    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;538    expect(539      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,540    ).toBeTruthy();541    expect(542      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,543    ).toBeTruthy();544545    Array.from(container.querySelectorAll("button"))546      .find((b) => (b.textContent ?? "").trim() === "remove")!547      .click();548    for (let i = 0; i < 10; i++) await Promise.resolve();549    const confirm = Array.from(550      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),551    ).find((b) => (b.textContent ?? "").trim() === "remove");552    confirm!.click();553    for (let i = 0; i < 30; i++) await Promise.resolve();554555    expect(sendMessageMock).toHaveBeenCalledWith({556      type: "UNPAIR_DEVICE",557      payload: { deviceId: "phone-1" },558    });559    // The list must be refreshed and the device must be gone. The regression560    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but561    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.562    expect(container.textContent).not.toContain("June's Phone");563    expect(container.textContent).not.toContain("PAIRED DEVICES");564  });565566  it("Settings keeps connection and recovery details collapsed until requested", async () => {567    sendMessageMock.mockImplementation(async (msg: any) => {568      if (msg?.type === "LIST_PAIRED_DEVICES") {569        return { ok: true, data: { devices: [] } };570      }571      return { ok: true, data: null };572    });573574    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);575    for (let i = 0; i < 30; i++) await Promise.resolve();576    Array.from(container.querySelectorAll("button"))577      .find((button) => /sync/i.test(button.textContent ?? ""))578      ?.click();579    for (let i = 0; i < 20; i++) await Promise.resolve();580581    expect(container.textContent).toContain("SYNC STATUS");582    expect(container.textContent).toContain("ADD A DEVICE");583    expect(container.textContent).not.toContain("Public relay");584    expect(container.textContent).not.toContain("Rotate sync identity");585586    const connection = Array.from(container.querySelectorAll("button"))587      .find((button) => button.textContent?.includes("CONNECTION"));588    connection?.click();589    await Promise.resolve();590    expect(container.textContent).toContain("Public relay");591592    const recovery = Array.from(container.querySelectorAll("button"))593      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));594    recovery?.click();595    await Promise.resolve();596    expect(container.textContent).toContain("Rotate sync identity");597  });598599  it("Settings requires current, new, and confirm passwords before enabling save", async () => {600    sendMessageMock.mockResolvedValue({ ok: true, data: null });601    const container = mount(602      <SettingsView603        entriesCount={1}604        breachMonitoring={true}605        lockOnClose={true}606        autoLockMinutes={5}607        trashRetentionDays={30}608        theme="dark"609        accentColor="peach"610        textSize="medium"611        reduceMotion={false}612        onBreachMonitoringChange={vi.fn()}613        onLockOnCloseChange={vi.fn()}614        onAutoLockMinutesChange={vi.fn()}615        onTrashRetentionChange={vi.fn()}616        onThemeChange={vi.fn()}617        onAccentChange={vi.fn()}618        onTextSizeChange={vi.fn()}619        onReduceMotionChange={vi.fn()}620        shortcuts={DEFAULT_BINDINGS}621        onShortcutsChange={vi.fn()}622        onBack={vi.fn()}623        onImport={vi.fn()}624        onExport={vi.fn()}625        onBreachCheckDone={vi.fn()}626      />627    );628    await flushView();629630    expect(container.querySelectorAll(".stk-card").length).toBe(7);631    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]632    // "open settings to card I (Peach Pro) instead of card II (Protection)".633    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");634635    const changeButton = Array.from(container.querySelectorAll("button")).find(636      (candidate) => candidate.textContent?.trim() === "change"637    ) as HTMLButtonElement;638    changeButton.click();639    await flushView();640641    const saveButton = Array.from(container.querySelectorAll("button")).find(642      (candidate) => candidate.textContent?.trim() === "save new password"643    ) as HTMLButtonElement;644    expect(saveButton.disabled).toBe(true);645646    const inputs = container.querySelectorAll('input[type="password"]');647    inputText(inputs[0] as HTMLInputElement, "current-pass");648    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");649    await flushView();650    expect(saveButton.disabled).toBe(true);651652    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");653    await flushView();654    expect(saveButton.disabled).toBe(false);655  });656657  it("Settings feedback highlights the selected category and hides the form after submit", async () => {658    sendMessageMock.mockResolvedValue({ ok: true, data: null });659    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({660      ok: true,661      json: async () => ({ success: true }),662    }));663    vi.stubGlobal("fetch", fetchMock);664665    const container = mount(666      <SettingsView667        entriesCount={1}668        breachMonitoring={true}669        lockOnClose={true}670        autoLockMinutes={5}671        trashRetentionDays={30}672        theme="dark"673        accentColor="peach"674        textSize="medium"675        reduceMotion={false}676        initialSection="feedback"677        onBreachMonitoringChange={vi.fn()}678        onLockOnCloseChange={vi.fn()}679        onAutoLockMinutesChange={vi.fn()}680        onTrashRetentionChange={vi.fn()}681        onThemeChange={vi.fn()}682        onAccentChange={vi.fn()}683        onTextSizeChange={vi.fn()}684        onReduceMotionChange={vi.fn()}685        shortcuts={DEFAULT_BINDINGS}686        onShortcutsChange={vi.fn()}687        onBack={vi.fn()}688        onImport={vi.fn()}689        onExport={vi.fn()}690        onBreachCheckDone={vi.fn()}691      />692    );693    await flushView();694695    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;696    expect(feedbackCard.classList.contains("is-active")).toBe(true);697698    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];699    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;700    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;701    expect(bugButton.getAttribute("aria-pressed")).toBe("true");702    expect(featureButton.getAttribute("aria-pressed")).toBe("false");703704    featureButton.click();705    await flushView();706707    expect(bugButton.getAttribute("aria-pressed")).toBe("false");708    expect(featureButton.getAttribute("aria-pressed")).toBe("true");709    expect(featureButton.classList.contains("is-active")).toBe(true);710711    inputText(712      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,713      '<script>alert("xss")</script> Please make this easier to see & safer.'714    );715    const sendButton = Array.from(container.querySelectorAll("button")).find(716      (candidate) => candidate.textContent?.trim() === "Send"717    ) as HTMLButtonElement;718    sendButton.click();719720    await vi.waitFor(() => {721      expect(container.textContent).toContain("Thank you");722    });723724    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));725    expect(requestBody.subject).toContain("Feature request");726    expect(requestBody.message).toBe(727      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."728    );729    expect(requestBody.message).not.toContain("<script>");730    expect(container.textContent).toContain("Someone will process it soon");731    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();732    expect(container.querySelector(".peach-feedback-submit")).toBeNull();733  });734735  it("Settings keeps license activation on the full-screen celebration path", async () => {736    const onLicenseChange = vi.fn(async () => undefined);737    sendMessageMock.mockImplementation(async (msg: unknown) => {738      const type = (msg as { type?: string }).type;739      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };740      if (type === "ACTIVATE_LICENSE") {741        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };742      }743      if (type === "GET_ENTRIES") return { ok: true, data: [] };744      return { ok: true, data: null };745    });746747    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);748    await flushView();749750    inputText(751      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,752      "PEACH-TEST-KEY",753    );754    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;755    apply.click();756757    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));758    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);759    expect(container.textContent).not.toContain("thank you ✦");760  });761762  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {763    sendMessageMock.mockImplementation(async (msg: unknown) => {764      const type = (msg as { type?: string }).type;765      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };766      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };767      if (type === "GET_ENTRIES") return { ok: true, data: [] };768      return { ok: true, data: null };769    });770771    const onLicenseChangeFromStar = vi.fn();772    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);773    await flushView();774    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();775    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);776    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();777778    const onLicenseChangeFromPeach = vi.fn();779    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);780    await flushView();781    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();782    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);783    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();784  });785786  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {787    sendMessageMock.mockResolvedValue({ ok: true, data: null });788    const fetchMock = vi.fn();789    const requestMock = vi.fn(async () => false);790    vi.stubGlobal("fetch", fetchMock);791    vi.stubGlobal("browser", {792      permissions: {793        getAll: vi.fn(async () => ({ data_collection: [] })),794        request: requestMock,795      },796    });797798    const container = mount(799      <SettingsView800        entriesCount={1}801        breachMonitoring={true}802        lockOnClose={true}803        autoLockMinutes={5}804        trashRetentionDays={30}805        theme="dark"806        accentColor="peach"807        textSize="medium"808        reduceMotion={false}809        initialSection="feedback"810        onBreachMonitoringChange={vi.fn()}811        onLockOnCloseChange={vi.fn()}812        onAutoLockMinutesChange={vi.fn()}813        onTrashRetentionChange={vi.fn()}814        onThemeChange={vi.fn()}815        onAccentChange={vi.fn()}816        onTextSizeChange={vi.fn()}817        onReduceMotionChange={vi.fn()}818        shortcuts={DEFAULT_BINDINGS}819        onShortcutsChange={vi.fn()}820        onBack={vi.fn()}821        onImport={vi.fn()}822        onExport={vi.fn()}823        onBreachCheckDone={vi.fn()}824      />825    );826    await flushView();827828    inputText(829      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,830      "Please improve the feedback flow."831    );832    const sendButton = Array.from(container.querySelectorAll("button")).find(833      (candidate) => candidate.textContent?.trim() === "Send"834    ) as HTMLButtonElement;835    sendButton.click();836837    await vi.waitFor(() => {838      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });839    });840    expect(fetchMock).not.toHaveBeenCalled();841    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();842  });843844  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {845    sendMessageMock.mockImplementation(async (msg: unknown) => {846      const m = msg as { type: string };847      if (m.type === "GET_RECOVERY_WORDS") {848        return {849          ok: true,850          data: [851            "abandon", "ability", "able", "about", "above", "absent",852            "absorb", "abstract", "absurd", "abuse", "access", "accident",853          ],854        };855      }856      return { ok: true, data: null };857    });858    const onAutoLockMinutesChange = vi.fn();859    const onThemeChange = vi.fn();860    const container = mount(861      <SettingsView862        entriesCount={1}863        breachMonitoring={true}864        lockOnClose={true}865        autoLockMinutes={5}866        trashRetentionDays={30}867        theme="dark"868        accentColor="peach"869        textSize="medium"870        reduceMotion={false}871        onBreachMonitoringChange={vi.fn()}872        onLockOnCloseChange={vi.fn()}873        onAutoLockMinutesChange={onAutoLockMinutesChange}874        onTrashRetentionChange={vi.fn()}875        onThemeChange={onThemeChange}876        onAccentChange={vi.fn()}877        onTextSizeChange={vi.fn()}878        onReduceMotionChange={vi.fn()}879        shortcuts={DEFAULT_BINDINGS}880        onShortcutsChange={vi.fn()}881        onBack={vi.fn()}882        onImport={vi.fn()}883        onExport={vi.fn()}884        onBreachCheckDone={vi.fn()}885      />886    );887    await flushView();888    sendMessageMock.mockClear();889890    // ── Auto-lock + theme changes (before password change) ─────────────891    const selects = container.querySelectorAll("select");892    changeSelect(selects[0] as HTMLSelectElement, "15");893    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);894895    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;896    appearanceCard.click();897    await flushView();898    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");899    const themeButton = Array.from(container.querySelectorAll("button")).find(900      (candidate) => candidate.textContent?.trim() === "Switch to light"901    ) as HTMLButtonElement;902    themeButton.click();903    expect(onThemeChange).toHaveBeenCalledWith("light");904905    // ── Password change → recovery words display ────────────────────────906    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;907    protectionCard.click();908    await flushView();909910    const changeButton = Array.from(container.querySelectorAll("button")).find(911      (candidate) => candidate.textContent?.trim() === "change"912    ) as HTMLButtonElement;913    changeButton.click();914    await flushView();915916    const inputs = container.querySelectorAll('input[type="password"]');917    inputText(inputs[0] as HTMLInputElement, "current-pass");918    inputText(inputs[1] as HTMLInputElement, "new-password-123");919    inputText(inputs[2] as HTMLInputElement, "new-password-123");920    await flushView();921922    const saveButton = Array.from(container.querySelectorAll("button")).find(923      (candidate) => candidate.textContent?.trim() === "save new password"924    ) as HTMLButtonElement;925    saveButton.click();926    await flushView();927928    expect(sendMessageMock).toHaveBeenCalledWith({929      type: "CHANGE_PASSWORD",930      payload: {931        currentPassword: "current-pass",932        newPassword: "new-password-123",933      },934    });935936    // After a successful password change the recovery word display replaces settings.937    expect(container.textContent).toContain("save your recovery words");938    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });939  });940941  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {942    sendMessageMock.mockResolvedValue({ ok: true });943    const container = mount(944      <EntryView945        entry={sampleEntry()}946        onDelete={vi.fn()}947        onFieldSaved={vi.fn(async () => undefined)}948      />949    );950951    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;952    passwordButton.click();953    await flushView();954955    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {956      type: "COPY",957      payload: { text: "secret123", label: "password", alreadyWritten: false },958    });959    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {960      type: "FIRE_CANARY_PING",961      payload: {962        webhookUrl: "https://example.com/hook",963        entryTitle: "GitHub",964        trigger: "password_copied",965      },966    });967968    sendMessageMock.mockClear();969    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;970    usernameButton.click();971    await flushView();972973    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {974      type: "COPY",975      payload: { text: "june", label: "username", alreadyWritten: false },976    });977    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {978      type: "FIRE_CANARY_PING",979      payload: {980        webhookUrl: "https://example.com/hook",981        entryTitle: "GitHub",982        trigger: "username_copied",983      },984    });985  });986987  it("Entry shows Advanced for passkey-bearing credential entries", async () => {988    const container = mount(989      <EntryView990        entry={sampleEntry({991          type: "passkey",992          username: undefined,993          password: undefined,994          totp: undefined,995          canary: undefined,996          passkey: {997            rpId: "vercel.com",998            rpName: "Vercel",999            credentialId: "cred-1",1000            publicKeyCose: "cose",1001            userHandle: "handle",1002            userName: "june",1003            userDisplayName: "June",1004            algorithm: -7,1005            signCount: 0,1006            aaguid: "",1007            backupEligible: false,1008            backupState: false,1009            createdAt: 1,1010          },1011        })}1012        onDelete={vi.fn()}1013        onFieldSaved={vi.fn(async () => undefined)}1014      />1015    );10161017    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1018      (button) => button.textContent?.includes("Advanced")1019    ) as HTMLButtonElement | undefined;1020    expect(advanced).toBeDefined();1021    expect(advanced?.textContent).toContain("+ Advanced");10221023    advanced!.click();1024    await flushView();10251026    expect(container.textContent).toContain("passkey · vercel.com");1027  });10281029  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1030    sendMessageMock.mockResolvedValue({ ok: true });1031    const onFieldSaved = vi.fn(async () => undefined);1032    const container = mount(1033      <EntryView1034        entry={sampleEntry()}1035        onDelete={vi.fn()}1036        onFieldSaved={onFieldSaved}1037      />1038    );10391040    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1041    passwordValue.click();1042    await flushView();10431044    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1045    inputText(passwordInput, "");1046    await flushView();10471048    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1049    saveButton.click();1050    await flushView();10511052    expect(sendMessageMock).not.toHaveBeenCalledWith(1053      expect.objectContaining({ type: "UPDATE_ENTRY" })1054    );1055    expect(onFieldSaved).not.toHaveBeenCalled();10561057    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1058    totpMain.click();10591060    await vi.waitFor(() => {1061      expect(sendMessageMock).toHaveBeenLastCalledWith({1062        type: "COPY",1063        payload: { text: "123456", label: "totp", alreadyWritten: false },1064      });1065    }, { timeout: 500 });1066  });10671068  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1069    sendMessageMock.mockResolvedValue({ ok: true });1070    const container = mount(1071      <EntryView1072        entry={sampleEntry()}1073        onDelete={vi.fn()}1074        onFieldSaved={vi.fn(async () => undefined)}1075      />1076    );10771078    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1079    usernameValue.click();1080    await flushView();10811082    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1083    usernameInput.blur();1084    await flushView();10851086    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1087    expect(sendMessageMock).not.toHaveBeenCalledWith(1088      expect.objectContaining({ type: "UPDATE_ENTRY" })1089    );1090  });10911092  it("Entry keeps the full used-email list available while editing an email", async () => {1093    const container = mount(1094      <EntryView1095        entry={sampleEntry({ email: "[email protected]" })}1096        entries={[1097          sampleEntry({ email: "[email protected]" }),1098          sampleEntry({ id: "entry-2", email: "[email protected]" }),1099          sampleEntry({ id: "entry-3", username: "duckuccino" }),1100        ]}1101        onDelete={vi.fn()}1102        onFieldSaved={vi.fn(async () => undefined)}1103      />1104    );11051106    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1107      (card) => card.querySelector(".peach-label")?.textContent === "email"1108    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1109    emailValue.click();1110    await flushView();11111112    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1113      .map((element) => element.textContent);1114    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1115  });11161117  it("Entry lets TOTP secrets be edited inline", async () => {1118    sendMessageMock.mockResolvedValue({ ok: true });1119    const onFieldSaved = vi.fn(async () => undefined);1120    const container = mount(1121      <EntryView1122        entry={sampleEntry()}1123        onDelete={vi.fn()}1124        onFieldSaved={onFieldSaved}1125      />1126    );11271128    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1129    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1130    await flushView();11311132    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1133    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1134    await flushView();11351136    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1137    saveButton.click();1138    await flushView();11391140    expect(sendMessageMock).toHaveBeenCalledWith({1141      type: "UPDATE_ENTRY",1142      payload: {1143        id: "entry-1",1144        patch: { totp: "NB2W45DFOIZA====" },1145      },1146    });1147    expect(onFieldSaved).toHaveBeenCalled();1148  });11491150  it("Vault seeds and focuses search when a search seed is provided", async () => {1151    const container = mount(1152      <VaultView1153        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1154        sortOrder="url-first"1155        searchSeed="g"1156        searchSeedNonce={1}1157        onSelectEntry={vi.fn()}1158        onSortOrderChange={vi.fn()}1159        onAdd={vi.fn()}1160        onClose={vi.fn()}1161      />1162    );11631164    await flushView();11651166    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1167    expect(search.value).toBe("g");1168    expect(document.activeElement).toBe(search);1169  });11701171  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1172    parseImportSourceMock.mockResolvedValue(1173      sampleImportResult({1174        entries: [1175          {1176            type: "login",1177            title: "Imported site",1178            username: "imported-user",1179            password: "imported-pass",1180            url: "https://imported.test",1181          },1182        ],1183      })1184    );1185    sendMessageMock.mockImplementation(async (msg: unknown) => {1186      const type = (msg as { type?: string }).type;1187      if (type === "GET_ENTRIES") {1188        return {1189          ok: true,1190          data: [1191            sampleEntry({1192              id: "existing-1",1193              title: "Imported site",1194              username: "imported-user",1195              url: "https://imported.test",1196            }),1197          ],1198        };1199      }1200      if (type === "BATCH_ADD_ENTRIES") {1201        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1202      }1203      return { ok: true };1204    });1205    const onImport = vi.fn(async () => undefined);1206    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1207    await flushView();1208    sendMessageMock.mockClear();12091210    await selectImportFile(container);1211    findButton(container, "Import 1").click();1212    await flushView();12131214    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1215    expect(sendMessageMock).toHaveBeenCalledWith({1216      type: "BATCH_ADD_ENTRIES",1217      payload: {1218        entries: [1219          expect.objectContaining({1220            title: "Imported site",1221            username: "imported-user",1222            password: "imported-pass",1223            url: "https://imported.test",1224          }),1225        ],1226      },1227    });1228    expect(onImport).toHaveBeenCalled();1229    expect(container.textContent).toContain("1 entry appears to be duplicates");1230  });12311232  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1233    parseImportSourceMock.mockResolvedValue(sampleImportResult());1234    sendMessageMock.mockImplementation(async (msg: unknown) => {1235      const type = (msg as { type?: string }).type;1236      if (type === "GET_ENTRIES") {1237        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1238      }1239      if (type === "CLEAR_VAULT_ENTRIES") {1240        return { ok: true };1241      }1242      if (type === "BATCH_ADD_ENTRIES") {1243        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1244      }1245      return { ok: true };1246    });1247    const onImport = vi.fn(async () => undefined);1248    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1249    await flushView();1250    sendMessageMock.mockClear();12511252    await selectImportFile(container);1253    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1254    await flushView();12551256    findButton(container, "Replace vault with 1").click();1257    await flushView();12581259    expect(sendMessageMock).not.toHaveBeenCalledWith(1260      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1261    );1262    expect(container.textContent).toContain("Replace forever? This cannot be undone.");12631264    findButton(container, "Yes, replace forever").click();1265    await flushView();12661267    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1268    expect(sendMessageMock).toHaveBeenCalledWith({1269      type: "BATCH_ADD_ENTRIES",1270      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1271    });1272    expect(onImport).toHaveBeenCalled();1273  });12741275  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1276    parseImportSourceMock.mockResolvedValue(sampleImportResult());1277    sendMessageMock.mockImplementation(async (msg: unknown) => {1278      const type = (msg as { type?: string }).type;1279      if (type === "CREATE_VAULT") return { ok: true };1280      if (type === "BATCH_ADD_ENTRIES") {1281        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1282      }1283      return { ok: true };1284    });1285    const onImport = vi.fn(async () => undefined);1286    const container = mount(1287      <ImportView1288        mode="onboarding"1289        replacesExistingVault={true}1290        onBack={vi.fn()}1291        onImport={onImport}1292      />1293    );12941295    await selectImportFile(container);12961297    const createButton = findButton(container, "Create vault + import 1");1298    expect(createButton.disabled).toBe(true);1299    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1300    expect(container.textContent).toContain("Use at least 8 characters");13011302    const masterInput = container.querySelector(1303      'input[placeholder="new master password"]'1304    ) as HTMLInputElement;1305    const confirmInput = container.querySelector(1306      'input[placeholder="confirm master password"]'1307    ) as HTMLInputElement;1308    const revealButton = container.querySelector(1309      'button[aria-label="Show passwords"]'1310    ) as HTMLButtonElement;1311    expect(revealButton).toBeTruthy();1312    revealButton.click();1313    await flushView();1314    expect(masterInput.type).toBe("text");1315    expect(confirmInput.type).toBe("text");13161317    inputText(masterInput, "fresh-master-password");1318    inputText(confirmInput, "fresh-master-password");1319    await flushView();13201321    expect(createButton.disabled).toBe(false);1322    createButton.click();1323    await flushView();13241325    expect(sendMessageMock).toHaveBeenCalledWith({1326      type: "CREATE_VAULT",1327      payload: { password: "fresh-master-password" },1328    });1329    expect(sendMessageMock).toHaveBeenCalledWith({1330      type: "BATCH_ADD_ENTRIES",1331      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1332    });1333    expect(onImport).toHaveBeenCalled();1334  });13351336  it("Import encrypted backups reparse with the backup password before import", async () => {1337    parseImportSourceMock1338      .mockResolvedValueOnce(1339        sampleImportResult({1340          format: "peach-json",1341          label: "Peach encrypted backup",1342          entries: [],1343          totalItems: 0,1344          requiresPassword: true,1345        })1346      )1347      .mockResolvedValueOnce(1348        sampleImportResult({1349          format: "peach-json",1350          label: "Peach encrypted backup",1351        })1352      );1353    sendMessageMock.mockImplementation(async (msg: unknown) => {1354      const type = (msg as { type?: string }).type;1355      if (type === "GET_ENTRIES") return { ok: true, data: [] };1356      if (type === "BATCH_ADD_ENTRIES") {1357        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1358      }1359      return { ok: true };1360    });1361    const onImport = vi.fn(async () => undefined);1362    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1363    await flushView();1364    sendMessageMock.mockClear();13651366    await selectImportFile(container, '{"type":"peach-export"}');1367    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1368    inputText(password, "backup-passphrase");1369    pressEnter(password);1370    await flushView();1371    await flushView();13721373    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1374    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");13751376    findButton(container, "Import 1").click();1377    await flushView();13781379    expect(sendMessageMock).toHaveBeenCalledWith({1380      type: "BATCH_ADD_ENTRIES",1381      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1382    });1383    expect(onImport).toHaveBeenCalled();1384  });1385});