// @vitest-environment jsdom import type { JSX } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Onboarding } from "./Onboarding"; import { AdoptVaultView, type AdoptVaultViewProps, } from "./AdoptVaultView"; import { flushView, inputText, renderView } from "./view-test-utils"; import { createPairingInvitation } from "../../shared/pairing-invitation"; const INVITATION = createPairingInvitation( "wss://relay.peachpasswords.com", "AAECAwQFBgcICQoLDA0ODw", ); vi.mock("@zxcvbn-ts/core", () => ({ zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }), zxcvbnOptions: { setOptions: vi.fn() }, })); vi.mock("@zxcvbn-ts/language-common", () => ({ adjacencyGraphs: {}, dictionary: {}, })); vi.mock("@zxcvbn-ts/language-en", () => ({ translations: {}, dictionary: {}, })); function button(container: HTMLElement, text: string): HTMLButtonElement { const found = Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").includes(text), ); if (!found) throw new Error(`button not found: ${text}`); return found as HTMLButtonElement; } async function enterInvitation(container: HTMLElement) { button(container, "Enter invitation manually").click(); await flushView(); inputText( container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement, INVITATION, ); await flushView(); } async function enterStrongPassword(container: HTMLElement) { inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn"); inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn"); await flushView(); } describe("vault adoption onboarding", () => { const cleanups: Array<() => void> = []; afterEach(() => { vi.useRealTimers(); while (cleanups.length) cleanups.pop()!(); }); function mounted(view: () => JSX.Element) { const rendered = renderView(view); cleanups.push(rendered.dispose); return rendered; } it("T-A21: Onboarding offers Create vault, Restore from backup, and I already have Peach", () => { vi.useFakeTimers(); const onComplete = vi.fn(); const onRestore = vi.fn(); const onAdopt = vi.fn(); const view = mounted(() => ); button(view.container, "next").click(); vi.advanceTimersByTime(220); button(view.container, "next").click(); vi.advanceTimersByTime(220); expect(view.container.textContent).toContain("Create vault"); expect(view.container.textContent).toContain("Restore from backup"); expect(view.container.textContent).toContain("I already have Peach"); button(view.container, "I already have Peach").click(); expect(onAdopt).toHaveBeenCalledTimes(1); }); it("T-A22: password screen uses the same minimum-length gate as create-vault", () => { const view = mounted(() => {}} onDone={() => {}} startAdoption={vi.fn()} />); const continueButton = button(view.container, "Continue"); expect(continueButton.disabled).toBe(true); inputText(view.container.querySelector("#adopt-password") as HTMLInputElement, "short"); inputText(view.container.querySelector("#adopt-confirm-password") as HTMLInputElement, "short"); expect(view.container.textContent).toContain("need 7 more characters"); expect(continueButton.disabled).toBe(true); }); it("T-A26: relay adoption cannot start until a valid invitation is entered", async () => { const startAdoption = vi.fn( async (_args: Parameters[0]) => {}, ); const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await flushView(); // No relay selector here: the code carries the relay, so this screen states // it rather than offering a choice it would overwrite // (docs/sync-relay-surfacing.md). expect(view.container.textContent).toContain("relay carried in the code you scan"); expect(view.container.querySelector(".peach-sync-mode")).toBeNull(); expect(view.container.querySelector(".peach-sync-mode-summary")).toBeNull(); button(view.container, "Enter invitation manually").click(); await flushView(); expect(button(view.container, "Connect securely").disabled).toBe(true); inputText( view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement, "peach-pair-v1:not-valid", ); await flushView(); expect(button(view.container, "Connect securely").disabled).toBe(true); inputText( view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement, INVITATION, ); await flushView(); expect(button(view.container, "Connect securely").disabled).toBe(false); expect(startAdoption).not.toHaveBeenCalled(); }); it("T-A27: the relay invitation is handed to the adoption transport", async () => { const startAdoption = vi.fn( async (_args: Parameters[0]) => {}, ); const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await enterInvitation(view.container); button(view.container, "Connect securely").click(); await flushView(); expect(startAdoption).toHaveBeenCalledTimes(1); expect(startAdoption.mock.calls[0][0]).toMatchObject({ connection: { invitation: INVITATION }, }); }); it("T-A23: adoption flow reaches the SAS screen", async () => { const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise }) => { await confirmSAS("895245"); }); const view = mounted(() => {}} onDone={() => {}} startAdoption={startAdoption} />); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await enterInvitation(view.container); button(view.container, "Connect securely").click(); await flushView(); expect(view.container.textContent).toContain("Confirm this code matches"); expect(view.container.textContent).toContain("8 9 5 2 4 5"); }); it("T-A24: success leaves a vault and opens Peach", async () => { const onDone = vi.fn(); const view = mounted( () => ( {}} onDone={onDone} startAdoption={vi.fn(async () => {})} /> ), ); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await enterInvitation(view.container); button(view.container, "Connect securely").click(); await flushView(); expect(view.container.textContent).toContain("Joined your vault."); button(view.container, "Open Peach").click(); expect(onDone).toHaveBeenCalledTimes(1); }); it("T-A25: SAS cancel keeps receiver vault-less and returns to the password step", async () => { const createVault = vi.fn(); const view = mounted( () => ( {}} onDone={() => {}} startAdoption={vi.fn(async ({ confirmSAS }) => { const ok = await confirmSAS("895245"); if (ok) await createVault(); else throw new Error("sas_rejected"); })} /> ), ); await enterStrongPassword(view.container); button(view.container, "Continue").click(); await enterInvitation(view.container); button(view.container, "Connect securely").click(); await flushView(); button(view.container, "Cancel").click(); await flushView(); expect(createVault).not.toHaveBeenCalled(); expect(view.container.textContent).toContain("Set a password for this device."); }); });