123import 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 110 111 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});