contracts.test.tsx

23.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 { EntryView } from "./Entry";12import { ImportView } from "./Import";13import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1415const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();16const triggerRingMock = vi.fn();17const triggerTotpRingMock = vi.fn();18const triggerPipPulseMock = vi.fn();19const exportEntriesToPeachJsonMock = 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  triggerTotpRing: () => triggerTotpRingMock(),27  triggerPipPulse: () => triggerPipPulseMock(),28}));2930vi.mock("../../shared/backup", () => ({31  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32}));3334vi.mock("../../shared/import", () => ({35  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),36}));3738vi.mock("../../shared/favicon", () => ({39  faviconUrl: () => null,40}));4142vi.mock("../../crypto/totp", () => ({43  generateTOTP: () => ({ code: "123 456", remaining: 24 }),44}));4546vi.mock("../components/GeneratorDrawer", () => ({47  GeneratorDrawer: () => null,48}));4950vi.mock("./CJKImport", () => ({51  CJKImportView: () => null,52}));5354vi.mock("./CJKExport", () => ({55  CJKExportView: () => null,56}));5758vi.mock("zxcvbn", () => ({59  default: (value: string) => zxcvbnMock(value),60}));6162describe("popup contract tests", () => {63  const disposers: Array<() => void> = [];6465  beforeEach(() => {66    document.body.innerHTML = "";67    sendMessageMock.mockReset();68    triggerRingMock.mockReset();69    triggerTotpRingMock.mockReset();70    triggerPipPulseMock.mockReset();71    exportEntriesToPeachJsonMock.mockReset();72    parseImportSourceMock.mockReset();73    zxcvbnMock.mockReset();74    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));75    parseImportSourceMock.mockResolvedValue({76      format: "unknown",77      label: "Unknown format",78      entries: [],79      totalItems: 0,80      skippedItems: 0,81      requiresPassword: false,82    });8384    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {85      return window.setTimeout(() => cb(0), 0) as unknown as number;86    });87    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));88    vi.stubGlobal("URL", {89      createObjectURL: vi.fn(() => "blob:peach-test"),90      revokeObjectURL: vi.fn(),91    });92    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9394    (globalThis as { chrome?: typeof chrome }).chrome = {95      runtime: {96        getManifest: () => ({ version: "1.4.43" }),97        onMessage: { addListener: vi.fn() },98      },99    } as unknown as typeof chrome;100  });101102  afterEach(() => {103    while (disposers.length > 0) {104      disposers.pop()?.();105    }106    vi.unstubAllGlobals();107  });108109  function mount(view: JSX.Element) {110    const mounted = renderView(view);111    disposers.push(mounted.dispose);112    return mounted.container;113  }114115  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {116    return {117      id: "entry-1",118      type: "login",119      title: "GitHub",120      username: "june",121      password: "secret123",122      url: "https://github.com",123      totp: "JBSWY3DPEHPK3PXP",124      canary: {125        webhookUrl: "https://example.com/hook",126      },127      createdAt: 1,128      modifiedAt: 1,129      ...overrides,130    };131  }132133  function sampleImportResult(134    overrides: Partial<ImportParseResult> = {}135  ): ImportParseResult {136    return {137      format: "csv",138      label: "CSV import",139      entries: [140        {141          type: "login",142          title: "Imported site",143          username: "imported-user",144          password: "imported-pass",145          url: "https://imported.test",146        },147      ],148      totalItems: 1,149      skippedItems: 0,150      requiresPassword: false,151      ...overrides,152    };153  }154155  async function selectImportFile(156    container: HTMLElement,157    contents = '{"export":"test"}'158  ) {159    const input = container.querySelector('input[type="file"]') as HTMLInputElement;160    const file = new File([contents], "import.json", { type: "application/json" });161    Object.defineProperty(input, "files", {162      configurable: true,163      value: [file],164    });165    input.dispatchEvent(new Event("change", { bubbles: true }));166    await flushView();167    await flushView();168  }169170  function findButton(container: HTMLElement, text: string): HTMLButtonElement {171    return Array.from(container.querySelectorAll("button")).find(172      (candidate) => candidate.textContent?.trim() === text173    ) as HTMLButtonElement;174  }175176  it("Lock sends UNLOCK with the entered passphrase", async () => {177    sendMessageMock.mockResolvedValue({ ok: true });178    const onUnlock = vi.fn(async () => undefined);179    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);180181    const password = container.querySelector("#unlock-password") as HTMLInputElement;182    inputText(password, "correct horse battery staple");183    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();184    await flushView();185186    expect(sendMessageMock).toHaveBeenCalledWith({187      type: "UNLOCK",188      payload: { password: "correct horse battery staple" },189    });190    expect(onUnlock).toHaveBeenCalled();191  });192193  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {194    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);195    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;196    const password = container.querySelector("#master-password") as HTMLInputElement;197    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;198199    inputText(password, "short");200    inputText(confirm, "short");201    await flushView();202    expect(createButton.disabled).toBe(true);203204    zxcvbnMock.mockImplementation(() => ({ score: 1 }));205    inputText(password, "weakweakweak");206    inputText(confirm, "weakweakweak");207    await flushView();208    expect(createButton.disabled).toBe(true);209  });210211  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {212    sendMessageMock.mockResolvedValue({ ok: true });213    zxcvbnMock.mockImplementation(() => ({ score: 3 }));214    const onUnlock = vi.fn(async () => undefined);215    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);216217    const password = container.querySelector("#master-password") as HTMLInputElement;218    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;219    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;220221    inputText(password, "correct horse battery staple");222    inputText(confirm, "correct horse battery staple");223    await flushView();224    expect(createButton.disabled).toBe(false);225226    pressEnter(confirm);227    await flushView();228229    expect(sendMessageMock).toHaveBeenCalledWith({230      type: "CREATE_VAULT",231      payload: { password: "correct horse battery staple" },232    });233    expect(onUnlock).toHaveBeenCalled();234  });235236  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {237    const container = mount(<ExportView onBack={vi.fn()} />);238239    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;240    const button = Array.from(container.querySelectorAll("button")).find(241      (candidate) => candidate.textContent?.trim() === "Download backup file"242    ) as HTMLButtonElement;243244    inputText(password, "too-short");245    await flushView();246247    expect(button.disabled).toBe(true);248    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();249  });250251  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {252    sendMessageMock.mockResolvedValue({253      ok: true,254      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],255    });256    exportEntriesToPeachJsonMock.mockResolvedValue({257      version: 2,258      type: "peach-export",259    });260    const container = mount(<ExportView onBack={vi.fn()} />);261262    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;263    const button = Array.from(container.querySelectorAll("button")).find(264      (candidate) => candidate.textContent?.trim() === "Download backup file"265    ) as HTMLButtonElement;266267    inputText(password, "correct horse battery staple");268    await flushView();269    button.click();270    await flushView();271272    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });273    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(274      [expect.objectContaining({ id: "entry-1" })],275      "correct horse battery staple"276    );277  });278279  it("Settings requires current, new, and confirm passwords before enabling save", async () => {280    sendMessageMock.mockResolvedValue({ ok: true, data: null });281    const container = mount(282      <SettingsView283        entriesCount={1}284        breachMonitoring={true}285        lockOnClose={true}286        autoLockMinutes={5}287        trashRetentionDays={30}288        theme="dark"289        onBreachMonitoringChange={vi.fn()}290        onLockOnCloseChange={vi.fn()}291        onAutoLockMinutesChange={vi.fn()}292        onTrashRetentionChange={vi.fn()}293        onThemeChange={vi.fn()}294        onBack={vi.fn()}295        onImport={vi.fn()}296        onExport={vi.fn()}297        onBreachCheckDone={vi.fn()}298      />299    );300    await flushView();301302    const changeButton = Array.from(container.querySelectorAll("button")).find(303      (candidate) => candidate.textContent?.trim() === "change"304    ) as HTMLButtonElement;305    changeButton.click();306    await flushView();307308    const saveButton = Array.from(container.querySelectorAll("button")).find(309      (candidate) => candidate.textContent?.trim() === "save new password"310    ) as HTMLButtonElement;311    expect(saveButton.disabled).toBe(true);312313    const inputs = container.querySelectorAll('input[type="password"]');314    inputText(inputs[0] as HTMLInputElement, "current-pass");315    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");316    await flushView();317    expect(saveButton.disabled).toBe(true);318319    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");320    await flushView();321    expect(saveButton.disabled).toBe(false);322  });323324  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {325    sendMessageMock.mockResolvedValue({ ok: true, data: null });326    const onAutoLockMinutesChange = vi.fn();327    const onThemeChange = vi.fn();328    const container = mount(329      <SettingsView330        entriesCount={1}331        breachMonitoring={true}332        lockOnClose={true}333        autoLockMinutes={5}334        trashRetentionDays={30}335        theme="dark"336        onBreachMonitoringChange={vi.fn()}337        onLockOnCloseChange={vi.fn()}338        onAutoLockMinutesChange={onAutoLockMinutesChange}339        onTrashRetentionChange={vi.fn()}340        onThemeChange={onThemeChange}341        onBack={vi.fn()}342        onImport={vi.fn()}343        onExport={vi.fn()}344        onBreachCheckDone={vi.fn()}345      />346    );347    await flushView();348    sendMessageMock.mockClear();349350    const selects = container.querySelectorAll("select");351    changeSelect(selects[0] as HTMLSelectElement, "15");352    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);353354    const themeButton = Array.from(container.querySelectorAll("button")).find(355      (candidate) => candidate.textContent?.trim() === "Switch to light"356    ) as HTMLButtonElement;357    themeButton.click();358    expect(onThemeChange).toHaveBeenCalledWith("light");359360    const changeButton = Array.from(container.querySelectorAll("button")).find(361      (candidate) => candidate.textContent?.trim() === "change"362    ) as HTMLButtonElement;363    changeButton.click();364    await flushView();365366    const inputs = container.querySelectorAll('input[type="password"]');367    inputText(inputs[0] as HTMLInputElement, "current-pass");368    inputText(inputs[1] as HTMLInputElement, "new-password-123");369    inputText(inputs[2] as HTMLInputElement, "new-password-123");370    await flushView();371372    const saveButton = Array.from(container.querySelectorAll("button")).find(373      (candidate) => candidate.textContent?.trim() === "save new password"374    ) as HTMLButtonElement;375    saveButton.click();376    await flushView();377378    expect(sendMessageMock).toHaveBeenCalledWith({379      type: "CHANGE_PASSWORD",380      payload: {381        currentPassword: "current-pass",382        newPassword: "new-password-123",383      },384    });385  });386387  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {388    sendMessageMock.mockResolvedValue({ ok: true });389    const container = mount(390      <EntryView391        entry={sampleEntry()}392        onDelete={vi.fn()}393        onFieldSaved={vi.fn(async () => undefined)}394      />395    );396397    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;398    passwordButton.click();399    await flushView();400401    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {402      type: "COPY",403      payload: { text: "secret123", label: "password" },404    });405    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {406      type: "FIRE_CANARY_PING",407      payload: {408        webhookUrl: "https://example.com/hook",409        entryTitle: "GitHub",410        trigger: "password_copied",411      },412    });413414    sendMessageMock.mockClear();415    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;416    usernameButton.click();417    await flushView();418419    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {420      type: "COPY",421      payload: { text: "june", label: "username" },422    });423    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {424      type: "FIRE_CANARY_PING",425      payload: {426        webhookUrl: "https://example.com/hook",427        entryTitle: "GitHub",428        trigger: "username_copied",429      },430    });431  });432433  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {434    sendMessageMock.mockResolvedValue({ ok: true });435    const onFieldSaved = vi.fn(async () => undefined);436    const container = mount(437      <EntryView438        entry={sampleEntry()}439        onDelete={vi.fn()}440        onFieldSaved={onFieldSaved}441      />442    );443444    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;445    passwordValue.click();446    await flushView();447448    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;449    inputText(passwordInput, "");450    await flushView();451452    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;453    saveButton.click();454    await flushView();455456    expect(sendMessageMock).not.toHaveBeenCalledWith(457      expect.objectContaining({ type: "UPDATE_ENTRY" })458    );459    expect(onFieldSaved).not.toHaveBeenCalled();460461    const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;462    totpButton.click();463    await flushView();464465    expect(sendMessageMock).toHaveBeenLastCalledWith({466      type: "COPY",467      payload: { text: "123456", label: "totp" },468    });469  });470471  it("Entry closes an unchanged inline editor when focus leaves it", async () => {472    sendMessageMock.mockResolvedValue({ ok: true });473    const container = mount(474      <EntryView475        entry={sampleEntry()}476        onDelete={vi.fn()}477        onFieldSaved={vi.fn(async () => undefined)}478      />479    );480481    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;482    usernameValue.click();483    await flushView();484485    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;486    usernameInput.blur();487    await flushView();488489    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();490    expect(sendMessageMock).not.toHaveBeenCalledWith(491      expect.objectContaining({ type: "UPDATE_ENTRY" })492    );493  });494495  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {496    parseImportSourceMock.mockResolvedValue(497      sampleImportResult({498        entries: [499          {500            type: "login",501            title: "Imported site",502            username: "imported-user",503            password: "imported-pass",504            url: "https://imported.test",505          },506        ],507      })508    );509    sendMessageMock.mockImplementation(async (msg: unknown) => {510      const type = (msg as { type?: string }).type;511      if (type === "GET_ENTRIES") {512        return {513          ok: true,514          data: [515            sampleEntry({516              id: "existing-1",517              title: "Imported site",518              username: "imported-user",519              url: "https://imported.test",520            }),521          ],522        };523      }524      if (type === "BATCH_ADD_ENTRIES") {525        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };526      }527      return { ok: true };528    });529    const onImport = vi.fn(async () => undefined);530    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);531    await flushView();532    sendMessageMock.mockClear();533534    await selectImportFile(container);535    findButton(container, "Import 1").click();536    await flushView();537538    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);539    expect(sendMessageMock).toHaveBeenCalledWith({540      type: "BATCH_ADD_ENTRIES",541      payload: {542        entries: [543          expect.objectContaining({544            title: "Imported site",545            username: "imported-user",546            password: "imported-pass",547            url: "https://imported.test",548          }),549        ],550      },551    });552    expect(onImport).toHaveBeenCalled();553    expect(container.textContent).toContain("1 entry appears to be duplicates");554  });555556  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {557    parseImportSourceMock.mockResolvedValue(sampleImportResult());558    sendMessageMock.mockImplementation(async (msg: unknown) => {559      const type = (msg as { type?: string }).type;560      if (type === "GET_ENTRIES") {561        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };562      }563      if (type === "CLEAR_VAULT_ENTRIES") {564        return { ok: true };565      }566      if (type === "BATCH_ADD_ENTRIES") {567        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };568      }569      return { ok: true };570    });571    const onImport = vi.fn(async () => undefined);572    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);573    await flushView();574    sendMessageMock.mockClear();575576    await selectImportFile(container);577    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();578    await flushView();579580    findButton(container, "Replace vault with 1").click();581    await flushView();582583    expect(sendMessageMock).not.toHaveBeenCalledWith(584      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })585    );586    expect(container.textContent).toContain("Replace forever? This cannot be undone.");587588    findButton(container, "Yes, replace forever").click();589    await flushView();590591    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });592    expect(sendMessageMock).toHaveBeenCalledWith({593      type: "BATCH_ADD_ENTRIES",594      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },595    });596    expect(onImport).toHaveBeenCalled();597  });598599  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {600    parseImportSourceMock.mockResolvedValue(sampleImportResult());601    sendMessageMock.mockImplementation(async (msg: unknown) => {602      const type = (msg as { type?: string }).type;603      if (type === "CREATE_VAULT") return { ok: true };604      if (type === "BATCH_ADD_ENTRIES") {605        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };606      }607      return { ok: true };608    });609    const onImport = vi.fn(async () => undefined);610    const container = mount(611      <ImportView612        mode="onboarding"613        replacesExistingVault={true}614        onBack={vi.fn()}615        onImport={onImport}616      />617    );618619    await selectImportFile(container);620621    const createButton = findButton(container, "Create vault + import 1");622    expect(createButton.disabled).toBe(true);623    expect(container.textContent).toContain("Restoring now replaces the locked local vault");624625    const inputs = container.querySelectorAll('input[type="password"]');626    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");627    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");628    await flushView();629630    expect(createButton.disabled).toBe(false);631    createButton.click();632    await flushView();633634    expect(sendMessageMock).toHaveBeenCalledWith({635      type: "CREATE_VAULT",636      payload: { password: "fresh-master-password" },637    });638    expect(sendMessageMock).toHaveBeenCalledWith({639      type: "BATCH_ADD_ENTRIES",640      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },641    });642    expect(onImport).toHaveBeenCalled();643  });644645  it("Import encrypted backups reparse with the backup password before import", async () => {646    parseImportSourceMock647      .mockResolvedValueOnce(648        sampleImportResult({649          format: "peach-json",650          label: "Peach encrypted backup",651          entries: [],652          totalItems: 0,653          requiresPassword: true,654        })655      )656      .mockResolvedValueOnce(657        sampleImportResult({658          format: "peach-json",659          label: "Peach encrypted backup",660        })661      );662    sendMessageMock.mockImplementation(async (msg: unknown) => {663      const type = (msg as { type?: string }).type;664      if (type === "GET_ENTRIES") return { ok: true, data: [] };665      if (type === "BATCH_ADD_ENTRIES") {666        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };667      }668      return { ok: true };669    });670    const onImport = vi.fn(async () => undefined);671    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);672    await flushView();673    sendMessageMock.mockClear();674675    await selectImportFile(container, '{"type":"peach-export"}');676    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;677    inputText(password, "backup-passphrase");678    pressEnter(password);679    await flushView();680    await flushView();681682    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);683    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");684685    findButton(container, "Import 1").click();686    await flushView();687688    expect(sendMessageMock).toHaveBeenCalledWith({689      type: "BATCH_ADD_ENTRIES",690      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },691    });692    expect(onImport).toHaveBeenCalled();693  });694});