AdoptVaultView.test.tsx

14.6 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { readFileSync } from "node:fs";5import { afterEach, describe, expect, it, vi } from "vitest";6import { ONBOARDING_CARD_STORAGE_KEY, ONBOARDING_LAST_CARD, Onboarding } from "./Onboarding";7import { AdoptVaultView } from "./AdoptVaultView";8import { flushView, inputText, renderView } from "./view-test-utils";910vi.mock("@zxcvbn-ts/core", () => ({11  zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),12  zxcvbnOptions: { setOptions: vi.fn() },13}));14vi.mock("@zxcvbn-ts/language-common", () => ({15  adjacencyGraphs: {},16  dictionary: {},17}));18vi.mock("@zxcvbn-ts/language-en", () => ({19  translations: {},20  dictionary: {},21}));2223function button(container: HTMLElement, text: string): HTMLButtonElement {24  const found = Array.from(container.querySelectorAll("button")).find((b) =>25    (b.textContent ?? "").includes(text),26  );27  if (!found) throw new Error(`button not found: ${text}`);28  return found as HTMLButtonElement;29}3031async function enterStrongPassword(container: HTMLElement) {32  inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");33  inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");34  await flushView();35}3637describe("vault adoption onboarding", () => {38  const cleanups: Array<() => void> = [];3940  afterEach(() => {41    vi.useRealTimers();42    while (cleanups.length) cleanups.pop()!();43  });4445  function mounted(view: () => JSX.Element) {46    const rendered = renderView(view);47    cleanups.push(rendered.dispose);48    return rendered;49  }5051  it("T-A21: Onboarding separates pairing from backup restore", () => {52    vi.useFakeTimers();53    const onComplete = vi.fn();54    const onRestore = vi.fn();55    const onAdopt = vi.fn();56    const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={onRestore} onAdopt={onAdopt} />);5758    button(view.container, "next").click();59    vi.advanceTimersByTime(220);60    button(view.container, "next").click();61    vi.advanceTimersByTime(220);6263    expect(view.container.textContent).toContain("Create vault");64    expect(view.container.textContent).toContain("Already using Peach?");65    expect(view.container.textContent).toContain("Pair with another device");66    expect(view.container.textContent).toContain("Scan a QR and confirm one 8-digit code.");67    expect(view.container.textContent).toContain("Restore a backup");68    button(view.container, "Pair with another device").click();69    expect(onAdopt).toHaveBeenCalledTimes(1);70  });7172  it("T-A21c: resumes on the final card when re-entered from a leave flow", async () => {73    const onComplete = vi.fn();74    const view = mounted(() => (75      <Onboarding onComplete={onComplete} onRestore={() => {}} onAdopt={() => {}} initialCard={ONBOARDING_LAST_CARD} />76    ));7778    // Landing straight on the final card shows the exit actions, not card 0.79    expect(view.container.textContent).toContain("Create vault");80    expect(view.container.textContent).toContain("Already using Peach?");81    expect(view.container.textContent).not.toContain("Two keys.");8283    // Back (matched by class — the "Restore a backup" label also contains the84    // substring "back") still responds and steps to the previous card.85    (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();86    expect(view.container.querySelector(".peach-onboarding-card")?.classList.contains("is-exiting")).toBe(true);87    await new Promise((r) => setTimeout(r, 300));88    expect(view.container.textContent).toContain("Two keys.");89    expect(view.container.textContent).not.toContain("Create vault");90  });9192  it("T-A21d: persists the card on every step so a closed popup resumes mid-onboarding", async () => {93    const original = Object.getOwnPropertyDescriptor(globalThis, "chrome");94    const sets: Array<Record<string, string>> = [];95    try {96      Object.defineProperty(globalThis, "chrome", {97        configurable: true,98        value: {99          storage: {100            local: {101              set: vi.fn(async (obj: Record<string, string>) => {102                sets.push(obj);103              }),104            },105          },106        },107      });108109      const view = mounted(() => (110        <Onboarding onComplete={() => {}} onRestore={() => {}} onAdopt={() => {}} />111      ));112      button(view.container, "next").click();113      await new Promise((r) => setTimeout(r, 300));114      (view.container.querySelector(".peach-onboarding-back") as HTMLButtonElement).click();115      await new Promise((r) => setTimeout(r, 300));116117      const saved = sets.flatMap((s) => Object.entries(s));118      expect(saved).toContainEqual([ONBOARDING_CARD_STORAGE_KEY, "1"]);119      expect(saved[saved.length - 1]).toEqual([ONBOARDING_CARD_STORAGE_KEY, "0"]);120    } finally {121      if (original) Object.defineProperty(globalThis, "chrome", original);122      else delete (globalThis as { chrome?: unknown }).chrome;123    }124  });125126  it("T-A21b: create-vault is the nav call-to-action; a final-card tap does not silently create", () => {127    vi.useFakeTimers();128    const onComplete = vi.fn();129    const view = mounted(() => <Onboarding onComplete={onComplete} onRestore={() => {}} onAdopt={() => {}} />);130131    button(view.container, "next").click();132    vi.advanceTimersByTime(220);133    button(view.container, "next").click();134    vi.advanceTimersByTime(220);135136    expect(view.container.textContent).toContain("Create vault");137138    (view.container.querySelector(".peach-onboarding-card") as HTMLElement).click();139    expect(onComplete).not.toHaveBeenCalled();140141    button(view.container, "Create vault").click();142    vi.advanceTimersByTime(250);143    expect(onComplete).toHaveBeenCalledTimes(1);144  });145146  it("T-A22: password screen uses the same minimum-length gate as create-vault", () => {147    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);148    const continueButton = button(view.container, "Continue");149    expect(continueButton.disabled).toBe(true);150    inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short");151    inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short");152    expect(view.container.textContent).toContain("need 7 more characters");153    expect(continueButton.disabled).toBe(true);154  });155156  it("T-A28: pair phase shows the pairing-request QR after a strong password", async () => {157    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);158    await enterStrongPassword(view.container);159    button(view.container, "Continue").click();160    await flushView();161162    // The browser is the source of the QR now — the phone scans it.163    // We assert the heading + copy; the actual QR is rendered by a background164    // call that's out of scope for a jsdom test (chrome.runtime is undefined).165    expect(view.container.textContent).toContain("Pair a device");166    expect(view.container.textContent).toContain("Scan this QR in Peach");167    expect(view.container.querySelector(".peach-adopt-qr-wrap")).toBeTruthy();168    expect(view.container.querySelectorAll(".peach-adopt-bird-stage .peach-pair-bird")).toHaveLength(2);169    // The scan button from the old flow is gone — the phone is the scanner now.170    expect(view.container.textContent).not.toContain("Enter invitation manually");171  });172173  it("pins the compact receiver stage above the shared pairing-stage cascade", () => {174    const css = readFileSync("src/popup/styles/popup.css", "utf8");175    const rule = css.match(176      /\.peach-pair-stage\.peach-adopt-bird-stage\s*\{([^}]*)\}/,177    )?.[1];178179    expect(rule).toContain("height: 102px");180    expect(rule).toContain("margin: -6px auto 2px");181    expect(css).toMatch(182      /\.peach-adopt-card--pair\s*\{[^}]*border:\s*0;/s,183    );184  });185186  it("shows the receiver SAS and requires explicit confirmation", async () => {187    type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;188    const listeners: RuntimeListener[] = [];189    Object.defineProperty(globalThis, "chrome", {190      configurable: true,191      value: {192        runtime: {193          lastError: undefined,194          sendMessage(195            message: { type?: string },196            callback: (response: unknown) => void,197          ) {198            if (message.type === "START_ADOPTION_RECEIVER") {199              callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });200            } else {201              callback({ ok: true });202            }203          },204          onMessage: {205            addListener(fn: RuntimeListener) { listeners.push(fn); },206            removeListener(fn: RuntimeListener) {207              const index = listeners.indexOf(fn);208              if (index >= 0) listeners.splice(index, 1);209            },210          },211        },212      },213    });214215    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);216    await enterStrongPassword(view.container);217    button(view.container, "Continue").click();218    await flushView();219220    for (const listener of listeners) {221      listener({222        type: "ADOPTION_SOURCE_SAS",223        payload: {224          id: "receiver-sas-id",225          sas: "48219371",226          displayOnly: false,227        },228      });229    }230    await flushView();231232    expect(view.container.textContent).toContain("48219371");233    expect(view.container.textContent).toContain("Match this code with your other device.");234    expect(view.container.textContent).toContain("This device will receive your vault and recovery access.");235    expect(view.container.querySelector(".peach-adopt-privacy")).toBeNull();236    expect(view.container.textContent).not.toContain("session ID");237    expect(button(view.container, "Confirm code")).toBeTruthy();238    expect(view.container.querySelector(".peach-adoption-sas")).toBeTruthy();239    expect(view.container.querySelector(".peach-pair-qr")).toBeNull();240    expect(view.container.textContent).not.toContain("They match");241    expect(view.container.textContent).not.toContain("They don't match");242243    for (const listener of listeners) {244      listener({245        type: "ADOPTION_SOURCE_RESULT",246        payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },247      });248    }249    await flushView();250251    expect(view.container.textContent).toContain("Pair freely");252    expect(view.container.textContent).toContain("at least one of these two devices");253    expect(view.container.textContent).toContain("$39 once");254    expect(view.container.querySelector('a[href*="buy.polar.sh"]')).toBeTruthy();255  });256257  it("PB-26-607: never renders a raw background error string", async () => {258    type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;259    const listeners: RuntimeListener[] = [];260    const RAW = "TypeError: cannot read property \"salt\" of undefined at deriveSlotKey (vault.ts:2841)";261    Object.defineProperty(globalThis, "chrome", {262      configurable: true,263      value: {264        runtime: {265          lastError: undefined,266          sendMessage(267            message: { type?: string },268            callback: (response: unknown) => void,269          ) {270            if (message.type === "START_ADOPTION_RECEIVER") {271              callback({ ok: false, error: RAW });272            } else {273              callback({ ok: true });274            }275          },276          onMessage: {277            addListener(fn: RuntimeListener) { listeners.push(fn); },278            removeListener(fn: RuntimeListener) {279              const index = listeners.indexOf(fn);280              if (index >= 0) listeners.splice(index, 1);281            },282          },283        },284      },285    });286287    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);288    await enterStrongPassword(view.container);289    button(view.container, "Continue").click();290    await flushView();291292    expect(view.container.textContent).toContain("could not start joining this vault");293    expect(view.container.textContent).not.toContain(RAW);294    expect(view.container.textContent).not.toContain("vault.ts");295296    // Same guarantee on the result-push path, which switches to the error phase.297    for (const listener of listeners) {298      listener({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: RAW } });299    }300    await flushView();301302    expect(view.container.textContent).toContain("Could not join the vault.");303    expect(view.container.textContent).toContain("the other device could not finish the transfer");304    expect(view.container.textContent).not.toContain(RAW);305    expect(view.container.textContent).not.toContain("vault.ts");306  });307308  it("continues straight to initial sync when receiver pairing completes", async () => {309    type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;310    const listeners: RuntimeListener[] = [];311    Object.defineProperty(globalThis, "chrome", {312      configurable: true,313      value: {314        runtime: {315          lastError: undefined,316          sendMessage(317            message: { type?: string },318            callback: (response: unknown) => void,319          ) {320            if (message.type === "START_ADOPTION_RECEIVER") {321              callback({ ok: true, data: { invitation: "peach-pair-v2:test" } });322            } else {323              callback({ ok: true });324            }325          },326          onMessage: {327            addListener(fn: RuntimeListener) { listeners.push(fn); },328            removeListener(fn: RuntimeListener) {329              const index = listeners.indexOf(fn);330              if (index >= 0) listeners.splice(index, 1);331            },332          },333        },334      },335    });336    const onDone = vi.fn();337    const view = mounted(() => (338      <AdoptVaultView onCancel={() => {}} onDone={onDone} />339    ));340    await enterStrongPassword(view.container);341    button(view.container, "Continue").click();342    await flushView();343    vi.useFakeTimers();344345    for (const listener of listeners) {346      listener({347        type: "ADOPTION_SOURCE_RESULT",348        payload: { status: "complete" },349      });350    }351352    expect(onDone).not.toHaveBeenCalled();353    expect(view.container.textContent).toContain("Paired");354    expect(view.container.textContent).toContain("Secure connection established");355    expect(view.container.querySelector(".peach-adopt-bird-stage.act-paired")).toBeTruthy();356    expect(view.container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);357    expect(view.container.querySelector(".peach-adopt-connection")).toBeTruthy();358    expect(view.container.textContent).not.toContain("Joined your vault");359    expect(view.container.textContent).not.toContain("Open Peach");360361    vi.advanceTimersByTime(4_999);362    expect(onDone).not.toHaveBeenCalled();363    vi.advanceTimersByTime(1);364    expect(onDone).toHaveBeenCalledOnce();365  });366});