AdoptVaultView.test.tsx

8.1 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, describe, expect, it, vi } from "vitest";5import { Onboarding } from "./Onboarding";6import {7  AdoptVaultView,8  type AdoptVaultViewProps,9} from "./AdoptVaultView";10import { flushView, inputText, renderView } from "./view-test-utils";11import { createPairingInvitation } from "../../shared/pairing-invitation";1213const INVITATION = createPairingInvitation(14  "wss://relay.peachpasswords.com",15  "AAECAwQFBgcICQoLDA0ODw",16);1718vi.mock("@zxcvbn-ts/core", () => ({19  zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),20  zxcvbnOptions: { setOptions: vi.fn() },21}));22vi.mock("@zxcvbn-ts/language-common", () => ({23  adjacencyGraphs: {},24  dictionary: {},25}));26vi.mock("@zxcvbn-ts/language-en", () => ({27  translations: {},28  dictionary: {},29}));3031function button(container: HTMLElement, text: string): HTMLButtonElement {32  const found = Array.from(container.querySelectorAll("button")).find((b) =>33    (b.textContent ?? "").includes(text),34  );35  if (!found) throw new Error(`button not found: ${text}`);36  return found as HTMLButtonElement;37}3839async function enterInvitation(container: HTMLElement) {40  button(container, "Enter invitation manually").click();41  await flushView();42  inputText(43    container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,44    INVITATION,45  );46  await flushView();47}4849async function enterStrongPassword(container: HTMLElement) {50  inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");51  inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");52  await flushView();53}5455describe("vault adoption onboarding", () => {56  const cleanups: Array<() => void> = [];5758  afterEach(() => {59    vi.useRealTimers();60    while (cleanups.length) cleanups.pop()!();61  });6263  function mounted(view: () => JSX.Element) {64    const rendered = renderView(view);65    cleanups.push(rendered.dispose);66    return rendered;67  }6869  it("T-A21: Onboarding separates pairing from backup restore", () => {70    vi.useFakeTimers();71    const onComplete = vi.fn();72    const onRestore = vi.fn();73    const onAdopt = vi.fn();74    const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={onRestore} onAdopt={onAdopt} />);7576    button(view.container, "next").click();77    vi.advanceTimersByTime(220);78    button(view.container, "next").click();79    vi.advanceTimersByTime(220);8081    expect(view.container.textContent).toContain("Create vault");82    expect(view.container.textContent).toContain("Already using Peach?");83    expect(view.container.textContent).toContain("Pair with another device");84    expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code.");85    expect(view.container.textContent).toContain("Restore a backup");86    button(view.container, "Pair with another device").click();87    expect(onAdopt).toHaveBeenCalledTimes(1);88  });8990  it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {91    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={vi.fn()} />);92    const continueButton = button(view.container, "Continue");93    expect(continueButton.disabled).toBe(true);94    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");95    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");96    expect(view.container.textContent).toContain("need 7 more characters");97    expect(continueButton.disabled).toBe(true);98  });99100  it("T-A26: relay adoption cannot start until a valid invitation is entered", async () => {101    const startAdoption = vi.fn(102      async (_args: Parameters<AdoptVaultViewProps["startAdoption"]>[0]) => {},103    );104    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);105    await enterStrongPassword(view.container);106    button(view.container, "Continue").click();107    await flushView();108109    // No relay selector here: the code carries the relay, so this screen states110    // it rather than offering a choice it would overwrite111    // (docs/sync-relay-surfacing.md).112    expect(view.container.textContent).toContain("This extension is empty");113    expect(view.container.textContent).toContain("Public or Private relay");114    expect(view.container.querySelector(".peach-sync-mode")).toBeNull();115    expect(view.container.querySelector(".peach-sync-mode-summary")).toBeNull();116    button(view.container, "Enter invitation manually").click();117    await flushView();118    expect(button(view.container, "Connect securely").disabled).toBe(true);119    inputText(120      view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,121      "peach-pair-v1:not-valid",122    );123    await flushView();124    expect(button(view.container, "Connect securely").disabled).toBe(true);125126    inputText(127      view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,128      INVITATION,129    );130    await flushView();131    expect(button(view.container, "Connect securely").disabled).toBe(false);132    expect(startAdoption).not.toHaveBeenCalled();133  });134135  it("T-A27: the relay invitation is handed to the adoption transport", async () => {136    const startAdoption = vi.fn(137      async (_args: Parameters<AdoptVaultViewProps["startAdoption"]>[0]) => {},138    );139    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);140    await enterStrongPassword(view.container);141    button(view.container, "Continue").click();142    await enterInvitation(view.container);143    button(view.container, "Connect securely").click();144    await flushView();145146    expect(startAdoption).toHaveBeenCalledTimes(1);147    expect(startAdoption.mock.calls[0][0]).toMatchObject({148      connection: { invitation: INVITATION },149    });150  });151152  it("T-A23: adoption flow reaches the SAS screen", async () => {153    const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise<boolean> }) => {154      await confirmSAS("895245");155    });156    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);157    await enterStrongPassword(view.container);158    button(view.container, "Continue").click();159    await enterInvitation(view.container);160    button(view.container, "Connect securely").click();161    await flushView();162163    expect(view.container.textContent).toContain("Confirm this code matches");164    expect(view.container.textContent).toContain("8 9 5 2 4 5");165  });166167  it("T-A24: success leaves a vault and opens Peach", async () => {168    const onDone = vi.fn();169    const view = mounted(170      () => (171        <AdoptVaultView172          onCancel={() => {}}173          onDone={onDone}174          startAdoption={vi.fn(async () => {})}175        />176      ),177    );178    await enterStrongPassword(view.container);179    button(view.container, "Continue").click();180    await enterInvitation(view.container);181    button(view.container, "Connect securely").click();182    await flushView();183184    expect(view.container.textContent).toContain("Joined your vault.");185    button(view.container, "Open Peach").click();186    expect(onDone).toHaveBeenCalledTimes(1);187  });188189  it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => {190    const createVault = vi.fn();191    const view = mounted(192      () => (193        <AdoptVaultView194          onCancel={() => {}}195          onDone={() => {}}196          startAdoption={vi.fn(async ({ confirmSAS }) => {197            const ok = await confirmSAS("895245");198            if (ok) await createVault();199            else throw new Error("sas_rejected");200          })}201        />202      ),203    );204    await enterStrongPassword(view.container);205    button(view.container, "Continue").click();206    await enterInvitation(view.container);207    button(view.container, "Connect securely").click();208    await flushView();209    button(view.container, "Cancel").click();210    await flushView();211212    expect(createVault).not.toHaveBeenCalled();213    expect(view.container.textContent).toContain("Set a password for this device.");214  });215});