contracts.test.tsx

22.5 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 triggerPipPulseMock = vi.fn();18const exportEntriesToPeachJsonMock = vi.fn();19const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();20const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2122vi.mock("../App", () => ({23  sendMessage: (msg: unknown) => sendMessageMock(msg),24  triggerRing: () => triggerRingMock(),25  triggerPipPulse: () => triggerPipPulseMock(),26}));2728vi.mock("../../shared/backup", () => ({29  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),30}));3132vi.mock("../../shared/import", () => ({33  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),34}));3536vi.mock("../../shared/favicon", () => ({37  faviconUrl: () => null,38}));3940vi.mock("../../crypto/totp", () => ({41  generateTOTP: () => ({ code: "123 456", remaining: 24 }),42}));4344vi.mock("../components/GeneratorDrawer", () => ({45  GeneratorDrawer: () => null,46}));4748vi.mock("./CJKImport", () => ({49  CJKImportView: () => null,50}));5152vi.mock("./CJKExport", () => ({53  CJKExportView: () => null,54}));5556vi.mock("zxcvbn", () => ({57  default: (value: string) => zxcvbnMock(value),58}));5960describe("popup contract tests", () => {61  const disposers: Array<() => void> = [];6263  beforeEach(() => {64    document.body.innerHTML = "";65    sendMessageMock.mockReset();66    triggerRingMock.mockReset();67    triggerPipPulseMock.mockReset();68    exportEntriesToPeachJsonMock.mockReset();69    parseImportSourceMock.mockReset();70    zxcvbnMock.mockReset();71    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));72    parseImportSourceMock.mockResolvedValue({73      format: "unknown",74      label: "Unknown format",75      entries: [],76      totalItems: 0,77      skippedItems: 0,78      requiresPassword: false,79    });8081    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {82      return window.setTimeout(() => cb(0), 0) as unknown as number;83    });84    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));85    vi.stubGlobal("URL", {86      createObjectURL: vi.fn(() => "blob:peach-test"),87      revokeObjectURL: vi.fn(),88    });89    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9091    (globalThis as { chrome?: typeof chrome }).chrome = {92      runtime: {93        getManifest: () => ({ version: "1.4.39" }),94        onMessage: { addListener: vi.fn() },95      },96    } as unknown as typeof chrome;97  });9899  afterEach(() => {100    while (disposers.length > 0) {101      disposers.pop()?.();102    }103    vi.unstubAllGlobals();104  });105106  function mount(view: JSX.Element) {107    const mounted = renderView(view);108    disposers.push(mounted.dispose);109    return mounted.container;110  }111112  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {113    return {114      id: "entry-1",115      type: "login",116      title: "GitHub",117      username: "june",118      password: "secret123",119      url: "https://github.com",120      totp: "JBSWY3DPEHPK3PXP",121      canary: {122        webhookUrl: "https://example.com/hook",123      },124      createdAt: 1,125      modifiedAt: 1,126      ...overrides,127    };128  }129130  function sampleImportResult(131    overrides: Partial<ImportParseResult> = {}132  ): ImportParseResult {133    return {134      format: "csv",135      label: "CSV import",136      entries: [137        {138          type: "login",139          title: "Imported site",140          username: "imported-user",141          password: "imported-pass",142          url: "https://imported.test",143        },144      ],145      totalItems: 1,146      skippedItems: 0,147      requiresPassword: false,148      ...overrides,149    };150  }151152  async function selectImportFile(153    container: HTMLElement,154    contents = '{"export":"test"}'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    sendMessageMock.mockResolvedValue({ ok: true });210    zxcvbnMock.mockImplementation(() => ({ score: 3 }));211    const onUnlock = vi.fn(async () => undefined);212    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);213214    const password = container.querySelector("#master-password") as HTMLInputElement;215    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;216    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;217218    inputText(password, "correct horse battery staple");219    inputText(confirm, "correct horse battery staple");220    await flushView();221    expect(createButton.disabled).toBe(false);222223    pressEnter(confirm);224    await flushView();225226    expect(sendMessageMock).toHaveBeenCalledWith({227      type: "CREATE_VAULT",228      payload: { password: "correct horse battery staple" },229    });230    expect(onUnlock).toHaveBeenCalled();231  });232233  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {234    const container = mount(<ExportView onBack={vi.fn()} />);235236    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;237    const button = Array.from(container.querySelectorAll("button")).find(238      (candidate) => candidate.textContent?.trim() === "Download backup file"239    ) as HTMLButtonElement;240241    inputText(password, "too-short");242    await flushView();243244    expect(button.disabled).toBe(true);245    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();246  });247248  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {249    sendMessageMock.mockResolvedValue({250      ok: true,251      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],252    });253    exportEntriesToPeachJsonMock.mockResolvedValue({254      version: 2,255      type: "peach-export",256    });257    const container = mount(<ExportView onBack={vi.fn()} />);258259    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260    const button = Array.from(container.querySelectorAll("button")).find(261      (candidate) => candidate.textContent?.trim() === "Download backup file"262    ) as HTMLButtonElement;263264    inputText(password, "correct horse battery staple");265    await flushView();266    button.click();267    await flushView();268269    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });270    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(271      [expect.objectContaining({ id: "entry-1" })],272      "correct horse battery staple"273    );274  });275276  it("Settings requires current, new, and confirm passwords before enabling save", async () => {277    sendMessageMock.mockResolvedValue({ ok: true, data: null });278    const container = mount(279      <SettingsView280        entriesCount={1}281        breachMonitoring={true}282        lockOnClose={true}283        autoLockMinutes={5}284        trashRetentionDays={30}285        theme="dark"286        onBreachMonitoringChange={vi.fn()}287        onLockOnCloseChange={vi.fn()}288        onAutoLockMinutesChange={vi.fn()}289        onTrashRetentionChange={vi.fn()}290        onThemeChange={vi.fn()}291        onBack={vi.fn()}292        onImport={vi.fn()}293        onExport={vi.fn()}294        onBreachCheckDone={vi.fn()}295      />296    );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.mockResolvedValue({ ok: true, data: null });323    const onAutoLockMinutesChange = vi.fn();324    const onThemeChange = vi.fn();325    const container = mount(326      <SettingsView327        entriesCount={1}328        breachMonitoring={true}329        lockOnClose={true}330        autoLockMinutes={5}331        trashRetentionDays={30}332        theme="dark"333        onBreachMonitoringChange={vi.fn()}334        onLockOnCloseChange={vi.fn()}335        onAutoLockMinutesChange={onAutoLockMinutesChange}336        onTrashRetentionChange={vi.fn()}337        onThemeChange={onThemeChange}338        onBack={vi.fn()}339        onImport={vi.fn()}340        onExport={vi.fn()}341        onBreachCheckDone={vi.fn()}342      />343    );344    await flushView();345    sendMessageMock.mockClear();346347    const selects = container.querySelectorAll("select");348    changeSelect(selects[0] as HTMLSelectElement, "15");349    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);350351    const themeButton = Array.from(container.querySelectorAll("button")).find(352      (candidate) => candidate.textContent?.trim() === "Switch to light"353    ) as HTMLButtonElement;354    themeButton.click();355    expect(onThemeChange).toHaveBeenCalledWith("light");356357    const changeButton = Array.from(container.querySelectorAll("button")).find(358      (candidate) => candidate.textContent?.trim() === "change"359    ) as HTMLButtonElement;360    changeButton.click();361    await flushView();362363    const inputs = container.querySelectorAll('input[type="password"]');364    inputText(inputs[0] as HTMLInputElement, "current-pass");365    inputText(inputs[1] as HTMLInputElement, "new-password-123");366    inputText(inputs[2] as HTMLInputElement, "new-password-123");367    await flushView();368369    const saveButton = Array.from(container.querySelectorAll("button")).find(370      (candidate) => candidate.textContent?.trim() === "save new password"371    ) as HTMLButtonElement;372    saveButton.click();373    await flushView();374375    expect(sendMessageMock).toHaveBeenCalledWith({376      type: "CHANGE_PASSWORD",377      payload: {378        currentPassword: "current-pass",379        newPassword: "new-password-123",380      },381    });382  });383384  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {385    sendMessageMock.mockResolvedValue({ ok: true });386    const container = mount(387      <EntryView388        entry={sampleEntry()}389        onDelete={vi.fn()}390        onFieldSaved={vi.fn(async () => undefined)}391      />392    );393394    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;395    passwordButton.click();396    await flushView();397398    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {399      type: "COPY",400      payload: { text: "secret123", label: "password" },401    });402    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {403      type: "FIRE_CANARY_PING",404      payload: {405        webhookUrl: "https://example.com/hook",406        entryTitle: "GitHub",407        trigger: "password_copied",408      },409    });410411    sendMessageMock.mockClear();412    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;413    usernameButton.click();414    await flushView();415416    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {417      type: "COPY",418      payload: { text: "june", label: "username" },419    });420    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {421      type: "FIRE_CANARY_PING",422      payload: {423        webhookUrl: "https://example.com/hook",424        entryTitle: "GitHub",425        trigger: "username_copied",426      },427    });428  });429430  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {431    sendMessageMock.mockResolvedValue({ ok: true });432    const onFieldSaved = vi.fn(async () => undefined);433    const container = mount(434      <EntryView435        entry={sampleEntry()}436        onDelete={vi.fn()}437        onFieldSaved={onFieldSaved}438      />439    );440441    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;442    passwordValue.click();443    await flushView();444445    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;446    inputText(passwordInput, "");447    await flushView();448449    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;450    saveButton.click();451    await flushView();452453    expect(sendMessageMock).not.toHaveBeenCalledWith(454      expect.objectContaining({ type: "UPDATE_ENTRY" })455    );456    expect(onFieldSaved).not.toHaveBeenCalled();457458    const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;459    totpButton.click();460    await flushView();461462    expect(sendMessageMock).toHaveBeenLastCalledWith({463      type: "COPY",464      payload: { text: "123456", label: "totp" },465    });466  });467468  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {469    parseImportSourceMock.mockResolvedValue(470      sampleImportResult({471        entries: [472          {473            type: "login",474            title: "Imported site",475            username: "imported-user",476            password: "imported-pass",477            url: "https://imported.test",478          },479        ],480      })481    );482    sendMessageMock.mockImplementation(async (msg: unknown) => {483      const type = (msg as { type?: string }).type;484      if (type === "GET_ENTRIES") {485        return {486          ok: true,487          data: [488            sampleEntry({489              id: "existing-1",490              title: "Imported site",491              username: "imported-user",492              url: "https://imported.test",493            }),494          ],495        };496      }497      if (type === "BATCH_ADD_ENTRIES") {498        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };499      }500      return { ok: true };501    });502    const onImport = vi.fn(async () => undefined);503    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);504    await flushView();505    sendMessageMock.mockClear();506507    await selectImportFile(container);508    findButton(container, "Import 1").click();509    await flushView();510511    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);512    expect(sendMessageMock).toHaveBeenCalledWith({513      type: "BATCH_ADD_ENTRIES",514      payload: {515        entries: [516          expect.objectContaining({517            title: "Imported site",518            username: "imported-user",519            password: "imported-pass",520            url: "https://imported.test",521          }),522        ],523      },524    });525    expect(onImport).toHaveBeenCalled();526    expect(container.textContent).toContain("1 entry appears to be duplicates");527  });528529  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {530    parseImportSourceMock.mockResolvedValue(sampleImportResult());531    sendMessageMock.mockImplementation(async (msg: unknown) => {532      const type = (msg as { type?: string }).type;533      if (type === "GET_ENTRIES") {534        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };535      }536      if (type === "CLEAR_VAULT_ENTRIES") {537        return { ok: true };538      }539      if (type === "BATCH_ADD_ENTRIES") {540        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };541      }542      return { ok: true };543    });544    const onImport = vi.fn(async () => undefined);545    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);546    await flushView();547    sendMessageMock.mockClear();548549    await selectImportFile(container);550    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();551    await flushView();552553    findButton(container, "Replace vault with 1").click();554    await flushView();555556    expect(sendMessageMock).not.toHaveBeenCalledWith(557      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })558    );559    expect(container.textContent).toContain("Replace forever? This cannot be undone.");560561    findButton(container, "Yes, replace forever").click();562    await flushView();563564    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });565    expect(sendMessageMock).toHaveBeenCalledWith({566      type: "BATCH_ADD_ENTRIES",567      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },568    });569    expect(onImport).toHaveBeenCalled();570  });571572  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {573    parseImportSourceMock.mockResolvedValue(sampleImportResult());574    sendMessageMock.mockImplementation(async (msg: unknown) => {575      const type = (msg as { type?: string }).type;576      if (type === "CREATE_VAULT") return { ok: true };577      if (type === "BATCH_ADD_ENTRIES") {578        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };579      }580      return { ok: true };581    });582    const onImport = vi.fn(async () => undefined);583    const container = mount(584      <ImportView585        mode="onboarding"586        replacesExistingVault={true}587        onBack={vi.fn()}588        onImport={onImport}589      />590    );591592    await selectImportFile(container);593594    const createButton = findButton(container, "Create vault + import 1");595    expect(createButton.disabled).toBe(true);596    expect(container.textContent).toContain("Restoring now replaces the locked local vault");597598    const inputs = container.querySelectorAll('input[type="password"]');599    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");600    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");601    await flushView();602603    expect(createButton.disabled).toBe(false);604    createButton.click();605    await flushView();606607    expect(sendMessageMock).toHaveBeenCalledWith({608      type: "CREATE_VAULT",609      payload: { password: "fresh-master-password" },610    });611    expect(sendMessageMock).toHaveBeenCalledWith({612      type: "BATCH_ADD_ENTRIES",613      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },614    });615    expect(onImport).toHaveBeenCalled();616  });617618  it("Import encrypted backups reparse with the backup password before import", async () => {619    parseImportSourceMock620      .mockResolvedValueOnce(621        sampleImportResult({622          format: "peach-json",623          label: "Peach encrypted backup",624          entries: [],625          totalItems: 0,626          requiresPassword: true,627        })628      )629      .mockResolvedValueOnce(630        sampleImportResult({631          format: "peach-json",632          label: "Peach encrypted backup",633        })634      );635    sendMessageMock.mockImplementation(async (msg: unknown) => {636      const type = (msg as { type?: string }).type;637      if (type === "GET_ENTRIES") return { ok: true, data: [] };638      if (type === "BATCH_ADD_ENTRIES") {639        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };640      }641      return { ok: true };642    });643    const onImport = vi.fn(async () => undefined);644    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);645    await flushView();646    sendMessageMock.mockClear();647648    await selectImportFile(container, '{"type":"peach-export"}');649    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;650    inputText(password, "backup-passphrase");651    pressEnter(password);652    await flushView();653    await flushView();654655    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);656    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");657658    findButton(container, "Import 1").click();659    await flushView();660661    expect(sendMessageMock).toHaveBeenCalledWith({662      type: "BATCH_ADD_ENTRIES",663      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },664    });665    expect(onImport).toHaveBeenCalled();666  });667});