123import 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 { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29 sendMessage: (msg: unknown) => sendMessageMock(msg),30 triggerRing: () => triggerRingMock(),31 triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35 sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43 const actual = await importOriginal<typeof import("../../shared/favicon")>();44 return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48 generateTOTP: () => ({ code: "123 456", remaining: 24 }),49 parseOtpauthURI: (value: string) =>50 value.startsWith("otpauth://")51 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52 : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56 GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60 RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64 zxcvbn: (value: string) => zxcvbnMock(value),65 zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68 adjacencyGraphs: {},69 dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72 translations: {},73 dictionary: {},74}));7576describe("popup contract tests", () => {77 const disposers: Array<() => void> = [];7879 beforeEach(() => {80 document.body.innerHTML = "";81 sendMessageMock.mockReset();82 triggerRingMock.mockReset();83 triggerPipPulseMock.mockReset();84 parseImportSourceMock.mockReset();85 zxcvbnMock.mockReset();86 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87 parseImportSourceMock.mockResolvedValue({88 format: "unknown",89 label: "Unknown format",90 entries: [],91 totalItems: 0,92 skippedItems: 0,93 });9495 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96 return window.setTimeout(() => cb(0), 0) as unknown as number;97 });98 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99 vi.stubGlobal("URL", {100 createObjectURL: vi.fn(() => "blob:peach-test"),101 revokeObjectURL: vi.fn(),102 });103 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105 (globalThis as { chrome?: typeof chrome }).chrome = {106 runtime: {107 getManifest: () => ({ version: "1.4.47" }),108 onMessage: { addListener: vi.fn() },109 },110 } as unknown as typeof chrome;111 });112113 afterEach(() => {114 while (disposers.length > 0) {115 disposers.pop()?.();116 }117 vi.restoreAllMocks();118 vi.unstubAllGlobals();119 });120121 function mount(view: JSX.Element | (() => JSX.Element)) {122 const mounted = renderView(view);123 disposers.push(mounted.dispose);124 return mounted.container;125 }126127 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128 return {129 id: "entry-1",130 type: "login",131 title: "GitHub",132 username: "june",133 password: "secret123",134 url: "https://github.com",135 totp: "JBSWY3DPEHPK3PXP",136 canary: {137 webhookUrl: "https://example.com/hook",138 },139 createdAt: 1,140 modifiedAt: 1,141 ...overrides,142 };143 }144145 function sampleImportResult(146 overrides: Partial<ImportParseResult> = {}147 ): ImportParseResult {148 return {149 format: "csv",150 label: "CSV import",151 entries: [152 {153 type: "login",154 title: "Imported site",155 username: "imported-user",156 password: "imported-pass",157 url: "https://imported.test",158 },159 ],160 totalItems: 1,161 skippedItems: 0,162 ...overrides,163 };164 }165166 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {167 return {168 entriesCount: 1,169 breachMonitoring: true,170 lockOnClose: true,171 autoLockMinutes: 5,172 trashRetentionDays: 30,173 theme: "dark" as const,174 accentColor: "peach" as const,175 textSize: "medium" as const,176 reduceMotion: false,177 onBreachMonitoringChange: vi.fn(),178 onLockOnCloseChange: vi.fn(),179 onAutoLockMinutesChange: vi.fn(),180 onTrashRetentionChange: vi.fn(),181 onThemeChange: vi.fn(),182 onAccentChange: vi.fn(),183 onTextSizeChange: vi.fn(),184 onReduceMotionChange: vi.fn(),185 shortcuts: DEFAULT_BINDINGS,186 onShortcutsChange: vi.fn(),187 onBack: vi.fn(),188 onImport: vi.fn(),189 onExport: vi.fn(),190 onBreachCheckDone: vi.fn(),191 ...overrides,192 };193 }194195 async function selectImportFile(196 container: HTMLElement,197 contents = '{"export":"test"}'198 ) {199 200 201 202 const fileCard = findButton(container, "Choose file →");203 if (fileCard) {204 fileCard.click();205 await flushView();206 }207 const input = container.querySelector('input[type="file"]') as HTMLInputElement;208 const file = new File([contents], "import.json", { type: "application/json" });209 Object.defineProperty(input, "files", {210 configurable: true,211 value: [file],212 });213 input.dispatchEvent(new Event("change", { bubbles: true }));214 await flushView();215 await flushView();216 }217218 function findButton(container: HTMLElement, text: string): HTMLButtonElement {219 return Array.from(container.querySelectorAll("button")).find(220 (candidate) => candidate.textContent?.trim() === text221 ) as HTMLButtonElement;222 }223224 it("Lock sends UNLOCK with the entered passphrase", async () => {225 sendMessageMock.mockResolvedValue({ ok: true });226 const onUnlock = vi.fn(async () => undefined);227 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);228229 const password = container.querySelector("#unlock-password") as HTMLInputElement;230 inputText(password, "correct horse battery staple");231 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();232 await flushView();233234 expect(sendMessageMock).toHaveBeenCalledWith({235 type: "UNLOCK",236 payload: { password: "correct horse battery staple" },237 });238 expect(onUnlock).toHaveBeenCalled();239 });240241 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {242 sendMessageMock.mockImplementation(async (msg: unknown) => {243 const type = (msg as { type: string }).type;244 if (type === "GET_PIN_STATUS") {245 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };246 }247 return type === "GET_SETTINGS"248 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }249 : { ok: true };250 });251 const onUnlock = vi.fn(async () => undefined);252 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);253 await flushView();254255 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");256 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();257 await flushView();258259 expect(sendMessageMock).toHaveBeenCalledWith({260 type: "UNLOCK_WITH_PIN",261 payload: { pin: "1234" },262 });263 expect(onUnlock).toHaveBeenCalled();264 expect(container.textContent).toContain("use master password");265 });266267 it("places the PIN method switch below the unlock button", async () => {268 sendMessageMock.mockImplementation(async (msg: unknown) => {269 const type = (msg as { type: string }).type;270 if (type === "GET_PIN_STATUS") {271 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };272 }273 return type === "GET_SETTINGS"274 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }275 : { ok: true };276 });277 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);278 await flushView();279280 const buttons = Array.from(container.querySelectorAll("button"));281 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");282 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");283 expect(pinIndex).toBeGreaterThan(unlockIndex);284 });285286 it("keeps standalone note creation in Notes mode", async () => {287 const chromeApi = globalThis.chrome as unknown as {288 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };289 };290 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };291292 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);293 await flushView();294295 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))296 .toEqual(["login", "card"]);297 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();298 });299300 it("gives Notes mode the full note-formatting toolbar", async () => {301 const container = mount(302 <NotesEditor303 note={null}304 onBack={vi.fn()}305 onSaved={vi.fn()}306 onDelete={vi.fn()}307 />308 );309 await flushView();310311 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();312 expect(container.querySelector('[title="Divider"]')).toBeTruthy();313 expect(container.querySelector('[title="Quote"]')).toBeTruthy();314 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);315 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);316 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");317 });318319 it("saves formatting applied by the Notes toolbar before leaving", async () => {320 const savedNote = {321 id: "note-1",322 type: "note" as const,323 title: "Untitled",324 notes: "<b>formatted</b>",325 createdAt: 1,326 modifiedAt: 2,327 };328 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });329 const originalExecCommand = document.execCommand;330 const execCommandMock = vi.fn(() => {331 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");332 if (body) body.innerHTML = "<b>formatted</b>";333 return true;334 });335 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });336337 try {338 const container = mount(339 <NotesEditor340 note={null}341 onBack={vi.fn()}342 onSaved={vi.fn()}343 onDelete={vi.fn()}344 />345 );346 await flushView();347 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(348 new MouseEvent("mousedown", { bubbles: true, cancelable: true })349 );350 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);351 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();352 await flushView();353354 expect(sendMessageMock).toHaveBeenCalledWith({355 type: "SAVE_NOTE",356 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },357 });358 } finally {359 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });360 }361 });362363 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {364 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);365 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;366 const password = container.querySelector("#master-password") as HTMLInputElement;367 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;368369 inputText(password, "short");370 inputText(confirm, "short");371 await flushView();372 expect(createButton.disabled).toBe(true);373374 zxcvbnMock.mockImplementation(() => ({ score: 1 }));375 inputText(password, "weakweakweak");376 inputText(confirm, "weakweakweak");377 await flushView();378 expect(createButton.disabled).toBe(true);379 });380381 it("Lock create-vault header pins the wordmark left with back on the right", async () => {382 const container = mount(383 <LockView384 hasVault={false}385 ready={true}386 onUnlock={vi.fn()}387 onBackToOnboarding={() => {}}388 />389 );390 const header = container.querySelector(".peach-newvault-header") as HTMLElement;391 const wordmark = header.querySelector(".peach-wordmark-stack");392 const back = header.querySelector(".peach-newvault-back");393 expect(wordmark).toBeTruthy();394 expect(back).toBeTruthy();395 396 397 expect(header.firstElementChild).toBe(wordmark);398 expect(header.lastElementChild).toBe(back);399 await flushView();400 });401402 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {403 404 sendMessageMock.mockImplementation(405 async (msg: unknown) => {406 const m = msg as { type: string; payload?: { password?: string } };407 if (m.type === "CREATE_VAULT") return { ok: true };408 if (m.type === "GET_RECOVERY_WORDS") {409 410 return {411 ok: true,412 data: [413 "abandon", "ability", "able", "about", "above", "absent",414 "absorb", "abstract", "absurd", "abuse", "access", "accident",415 ],416 };417 }418 return { ok: true };419 }420 );421 zxcvbnMock.mockImplementation(() => ({ score: 3 }));422 const onUnlock = vi.fn(async () => undefined);423 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);424425 const password = container.querySelector("#master-password") as HTMLInputElement;426 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;427 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;428429 inputText(password, "correct horse battery staple");430 inputText(confirm, "correct horse battery staple");431 await flushView();432 expect(createButton.disabled).toBe(false);433434 pressEnter(confirm);435 await flushView();436437 expect(sendMessageMock).toHaveBeenCalledWith({438 type: "CREATE_VAULT",439 payload: { password: "correct horse battery staple" },440 });441 442 443 expect(onUnlock).not.toHaveBeenCalled();444 });445446 it("recovery display asks for three distinct randomized word positions and confirms", async () => {447 const words12 = [448 "abandon", "ability", "able", "about", "above", "absent",449 "absorb", "abstract", "absurd", "abuse", "access", "accident",450 ];451 sendMessageMock.mockImplementation(async (msg: unknown) => {452 const type = (msg as { type?: string }).type;453 if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };454 return { ok: true };455 });456457 vi.useFakeTimers();458 const onDone = vi.fn();459 try {460 const container = mount(<RecoveryWordDisplayView onDone={onDone} />);461 462 await vi.advanceTimersByTimeAsync(0);463464 const prompts = Array.from(465 container.querySelectorAll(".peach-recovery-confirm-num")466 ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));467 expect(prompts).toHaveLength(3);468 expect(new Set(prompts).size).toBe(3);469 for (const p of prompts) {470 expect(p).toBeGreaterThanOrEqual(1);471 expect(p).toBeLessThanOrEqual(12);472 }473474 475 const inputs = Array.from(476 container.querySelectorAll(".peach-recovery-confirm-input")477 ) as HTMLInputElement[];478 expect(inputs).toHaveLength(3);479 expect(inputs[0]!.disabled).toBe(true);480 await vi.advanceTimersByTimeAsync(5000);481 expect(inputs[0]!.disabled).toBe(false);482483 prompts.forEach((pos, i) => {484 inputText(inputs[i]!, words12[pos - 1]!);485 });486487 const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;488 expect(confirmBtn.disabled).toBe(false);489 confirmBtn.click();490 await vi.advanceTimersByTimeAsync(700); 491492 expect(onDone).toHaveBeenCalledTimes(1);493 } finally {494 vi.useRealTimers();495 }496 });497498 it("Recovery words flow submits the new master password and completes", async () => {499 sendMessageMock.mockImplementation(async (msg: unknown) => {500 const type = (msg as { type?: string }).type;501 if (type === "VALIDATE_RECOVERY_WORDS") {502 return { ok: true, data: { valid: true, invalid: [] } };503 }504 if (type === "RECOVER_WITH_WORDS") return { ok: true };505 return { ok: true };506 });507 const canvasContext = {508 clearRect: vi.fn(),509 fillText: vi.fn(),510 beginPath: vi.fn(),511 closePath: vi.fn(),512 arc: vi.fn(),513 fill: vi.fn(),514 stroke: vi.fn(),515 save: vi.fn(),516 restore: vi.fn(),517 translate: vi.fn(),518 rotate: vi.fn(),519 scale: vi.fn(),520 drawImage: vi.fn(),521 measureText: vi.fn(() => ({ width: 0 })),522 setTransform: vi.fn(),523 fillRect: vi.fn(),524 strokeRect: vi.fn(),525 } as unknown as CanvasRenderingContext2D;526 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);527528 const onRecoveryComplete = vi.fn(async () => undefined);529 const container = mount(530 <RecoveryWordEntryView531 onCancel={vi.fn()}532 onRecoveryComplete={onRecoveryComplete}533 />534 );535536 const recoveryWords = WORD_LIST.slice(0, 12);537 for (let i = 0; i < recoveryWords.length; i++) {538 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;539 inputText(input, recoveryWords[i]!);540 }541 await flushView();542543 findButton(container, "verify words").click();544 await flushView();545546 expect(container.textContent).toContain("new master password");547548 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;549 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;550 inputText(newPassword, "fresh-recovery-password");551 inputText(confirmPassword, "fresh-recovery-password");552 await flushView();553554 findButton(container, "recover vault").click();555 await flushView();556557 expect(sendMessageMock).toHaveBeenCalledWith({558 type: "RECOVER_WITH_WORDS",559 payload: {560 words: recoveryWords,561 newPassword: "fresh-recovery-password",562 },563 });564 expect(onRecoveryComplete).toHaveBeenCalled();565 });566567 it("Export menu surfaces both backup formats", async () => {568 const container = mount(<ExportView onBack={vi.fn()} />);569 await flushView();570571 expect(container.textContent).toContain("PeachScript");572 expect(container.textContent).toContain("CSV");573 });574575 it("Lock can open directly on backup restore from onboarding", async () => {576 const container = mount(() => (577 <LockView578 hasVault={false}579 ready={true}580 onUnlock={vi.fn()}581 initialRestore={true}582 />583 ));584 await flushView();585586 expect(container.textContent).toContain("PeachScript");587 expect(container.textContent).toContain("Codex");588 expect(container.textContent).toContain("CSV");589 expect(container.querySelector("#master-password")).toBeNull();590 });591592 it("Lock restore back returns to onboarding, not the create-vault screen", async () => {593 const onBackToOnboarding = vi.fn();594 const container = mount(() => (595 <LockView596 hasVault={false}597 ready={true}598 onUnlock={vi.fn()}599 initialRestore={true}600 onBackToOnboarding={onBackToOnboarding}601 />602 ));603 await flushView();604605 (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();606 await flushView();607 expect(onBackToOnboarding).toHaveBeenCalledTimes(1);608 });609610 it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {611 const container = mount(() => (612 <LockView613 hasVault={false}614 ready={true}615 onUnlock={vi.fn()}616 initialRestore={true}617 initialRestoreSource="symbols"618 />619 ));620 await flushView();621622 expect(container.querySelector(".peach-restore-script")).toBeTruthy();623 expect(container.querySelector("#restore-backup-pass")).toBeTruthy();624 expect(container.querySelector(".peach-restore-sources")).toBeNull();625 });626627 it("Lock restore resumes on the picked CSV source, not the picker", async () => {628 const container = mount(() => (629 <LockView630 hasVault={false}631 ready={true}632 onUnlock={vi.fn()}633 initialRestore={true}634 initialRestoreSource="csv"635 />636 ));637 await flushView();638639 expect(container.querySelector("#restore-csv-pass")).toBeTruthy();640 expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();641 expect(container.querySelector(".peach-restore-sources")).toBeNull();642 });643644 it("Lock restore PeachScript form loads a dropped .peach file", async () => {645 const container = mount(() => (646 <LockView647 hasVault={false}648 ready={true}649 onUnlock={vi.fn()}650 initialRestore={true}651 initialRestoreSource="symbols"652 />653 ));654 await flushView();655656 const form = container.querySelector(".peach-restore-form") as HTMLElement;657 expect(form).toBeTruthy();658659 660 661 const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });662 const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {663 dataTransfer?: { files: File[] };664 };665 drop.dataTransfer = { files: [file] };666 form.dispatchEvent(drop);667 await flushView();668669 const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;670 expect(script.value).toBe("dropped-peach-backup");671 });672673 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {674 const device = {675 deviceId: "phone-1",676 deviceName: "June's Phone",677 deviceType: "android" as const,678 longTermPublicKeyHex: "a".repeat(64),679 signingPublicKeyHex: "b".repeat(64),680 pairedAt: 1,681 trustSource: "direct" as const,682 };683 sendMessageMock.mockImplementation(async (msg: unknown) => {684 const m = msg as { type: string };685 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };686 return { ok: true, data: null };687 });688689 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);690 for (let i = 0; i < 30; i++) await Promise.resolve();691692 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>693 /sync/i.test(b.textContent ?? ""),694 );695 openSync?.click();696 for (let i = 0; i < 30; i++) await Promise.resolve();697698 const remove = Array.from(container.querySelectorAll("button")).find(699 (b) => (b.textContent ?? "").trim() === "remove",700 );701 expect(remove).toBeTruthy();702703 704 705 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();706707 remove!.click();708 for (let i = 0; i < 10; i++) await Promise.resolve();709710 711 712 const note = container.querySelector(".peach-unpair-confirm-note");713 expect(note).toBeTruthy();714 const text = note!.textContent ?? "";715 expect(text).toContain("master password");716 expect(text).toContain("recovery words");717 expect(text).toContain("future changes");718 });719720 it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {721 sendMessageMock.mockImplementation(async (msg: unknown) => {722 const m = msg as { type: string };723 if (m.type === "GET_PENDING_VOUCHED_DEVICES") {724 return {725 ok: true,726 data: [{727 deviceId: "phone-source-id",728 deviceName: "June's Phone",729 deviceType: "android",730 longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),731 signingPublicKeyHex: "b".repeat(64),732 pairedAt: Date.UTC(2026, 8, 5),733 receivedAt: Date.UTC(2026, 8, 6, 20, 15),734 vouchedByDeviceId: "trusted-browser-id",735 }],736 };737 }738 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };739 return { ok: true, data: null };740 });741742 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);743 for (let i = 0; i < 30; i++) await Promise.resolve();744 Array.from(container.querySelectorAll("button"))745 .find((button) => /sync/i.test(button.textContent ?? ""))746 ?.click();747 for (let i = 0; i < 30; i++) await Promise.resolve();748749 expect(container.textContent).toContain("a1b2 c3d4 e5f6");750 expect(container.textContent).toContain("trusted-browser-id");751 expect(container.textContent).toContain("2026-09-06");752 expect(container.textContent).toContain("existing vault contents");753 });754755 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {756 const device = {757 deviceId: "phone-1",758 deviceName: "June's Phone",759 deviceType: "android" as const,760 longTermPublicKeyHex: "a".repeat(64),761 signingPublicKeyHex: "b".repeat(64),762 pairedAt: 1,763 trustSource: "direct" as const,764 };765 let paired: typeof device[] = [device];766 sendMessageMock.mockImplementation(async (msg: any) => {767 if (msg?.type === "LIST_PAIRED_DEVICES") {768 return { ok: true, data: { devices: [...paired] } };769 }770 if (msg?.type === "UNPAIR_DEVICE") {771 772 773 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);774 return { ok: true };775 }776 if (msg?.type === "RENAME_PAIRED_DEVICE") {777 paired = paired.map((d) =>778 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,779 );780 return { ok: true };781 }782 return { ok: true, data: null };783 });784785 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);786 for (let i = 0; i < 30; i++) await Promise.resolve();787 Array.from(container.querySelectorAll("button"))788 .find((b) => /sync/i.test(b.textContent ?? ""))789 ?.click();790 for (let i = 0; i < 30; i++) await Promise.resolve();791792 793 expect(container.textContent).toContain("PAIRED DEVICES");794 expect(container.textContent).toContain("June's Phone");795 expect(container.textContent).toContain("Android");796 Array.from(container.querySelectorAll("button"))797 .find((b) => (b.textContent ?? "").trim() === "rename")!798 .click();799 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;800 renameInput.value = "Travel phone";801 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));802 Array.from(container.querySelectorAll("button"))803 .find((b) => (b.textContent ?? "").trim() === "save")!804 .click();805 for (let i = 0; i < 30; i++) await Promise.resolve();806 expect(sendMessageMock).toHaveBeenCalledWith({807 type: "RENAME_PAIRED_DEVICE",808 payload: { deviceId: "phone-1", name: "Travel phone" },809 });810 expect(container.textContent).toContain("Travel phone");811 const syncNowButton = container.querySelector<HTMLButtonElement>(812 'button[aria-label="Sync this vault now"]',813 )!;814 const syncStatusRow = syncNowButton.closest(".peach-settings-row--meta");815 const syncCard = syncNowButton.closest('[data-settings-card-id="sync"]');816 expect(syncStatusRow).not.toBeNull();817 expect(syncStatusRow?.contains(syncNowButton)).toBe(true);818 expect(syncCard?.querySelectorAll(".peach-settings-row--meta")).toHaveLength(1);819 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));820 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;821 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;822 expect(823 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,824 ).toBeTruthy();825 expect(826 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,827 ).toBeTruthy();828829 Array.from(container.querySelectorAll("button"))830 .find((b) => (b.textContent ?? "").trim() === "remove")!831 .click();832 for (let i = 0; i < 10; i++) await Promise.resolve();833 const confirm = Array.from(834 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),835 ).find((b) => (b.textContent ?? "").trim() === "remove");836 confirm!.click();837 for (let i = 0; i < 30; i++) await Promise.resolve();838839 expect(sendMessageMock).toHaveBeenCalledWith({840 type: "UNPAIR_DEVICE",841 payload: { deviceId: "phone-1" },842 });843 844 845 846 expect(container.textContent).not.toContain("June's Phone");847 expect(container.textContent).not.toContain("PAIRED DEVICES");848 });849 it("Settings keeps the confirmation and shows revoke failures", async () => {850 const device = {851 deviceId: "phone-1",852 deviceName: "June's Phone",853 deviceType: "android" as const,854 longTermPublicKeyHex: "a".repeat(64),855 signingPublicKeyHex: "b".repeat(64),856 pairedAt: 1,857 trustSource: "direct" as const,858 };859 sendMessageMock.mockImplementation(async (msg: unknown) => {860 const message = msg as { type?: string; payload?: { deviceId?: string } };861 if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };862 if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };863 return { ok: true, data: null };864 });865866 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);867 for (let i = 0; i < 30; i++) await Promise.resolve();868 for (let i = 0; i < 30; i++) await Promise.resolve();869 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();870 for (let i = 0; i < 30; i++) await Promise.resolve();871 Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();872 for (let i = 0; i < 10; i++) await Promise.resolve();873 Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();874 for (let i = 0; i < 30; i++) await Promise.resolve();875876 expect(container.textContent).toContain("trust_data_corrupted");877 expect(container.textContent).toContain("June's Phone");878 });879880 it("Settings drops the secure-reset panel once the device is on the settled generation", async () => {881 const openSyncCard = async (state: "V2_ACTIVE" | "V2_ANCHOR" | "LEGACY_ACTIVE") => {882 sendMessageMock.mockImplementation(async (msg: unknown) => {883 const message = msg as { type?: string };884 if (message.type === "GET_SYNC_GENERATION") {885 return { ok: true, data: { schemaVersion: 1, generation: 2, state, createdAt: 1 } };886 }887 return { ok: true, data: null };888 });889 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);890 for (let i = 0; i < 30; i++) await Promise.resolve();891 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();892 for (let i = 0; i < 30; i++) await Promise.resolve();893 return container;894 };895896 897 const settled = await openSyncCard("V2_ACTIVE");898 expect(settled.textContent).not.toContain("SECURE SYNC RESET");899 expect(settled.textContent).toContain("ADD A DEVICE");900901 902 const anchored = await openSyncCard("V2_ANCHOR");903 expect(anchored.textContent).toContain("SECURE SYNC RESET");904 expect(anchored.textContent).toContain("Not re-paired");905906 const legacy = await openSyncCard("LEGACY_ACTIVE");907 expect(legacy.textContent).toContain("SECURE SYNC RESET");908 expect(legacy.textContent).toContain("Legacy Sync generation");909 });910911 it("Settings keeps connection and recovery details collapsed until requested", async () => {912 sendMessageMock.mockImplementation(async (msg: any) => {913 if (msg?.type === "LIST_PAIRED_DEVICES") {914 return { ok: true, data: { devices: [] } };915 }916 return { ok: true, data: null };917 });918919 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);920 for (let i = 0; i < 30; i++) await Promise.resolve();921 Array.from(container.querySelectorAll("button"))922 .find((button) => /sync/i.test(button.textContent ?? ""))923 ?.click();924 for (let i = 0; i < 20; i++) await Promise.resolve();925926 expect(container.textContent).toContain("SYNC STATUS");927 expect(container.textContent).toContain("ADD A DEVICE");928 expect(container.textContent).not.toContain("Public relay");929 expect(container.textContent).not.toContain("Rotate sync identity");930931 const connection = Array.from(container.querySelectorAll("button"))932 .find((button) => button.textContent?.includes("CONNECTION"));933 connection?.click();934 await Promise.resolve();935 expect(container.textContent).toContain("Public relay");936937 const recovery = Array.from(container.querySelectorAll("button"))938 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));939 recovery?.click();940 await Promise.resolve();941 expect(container.textContent).toContain("Rotate sync identity");942 });943944 it("Settings requires current, new, and confirm passwords before enabling save", async () => {945 sendMessageMock.mockResolvedValue({ ok: true, data: null });946 const container = mount(947 <SettingsView948 entriesCount={1}949 breachMonitoring={true}950 lockOnClose={true}951 autoLockMinutes={5}952 trashRetentionDays={30}953 theme="dark"954 accentColor="peach"955 textSize="medium"956 reduceMotion={false}957 onBreachMonitoringChange={vi.fn()}958 onLockOnCloseChange={vi.fn()}959 onAutoLockMinutesChange={vi.fn()}960 onTrashRetentionChange={vi.fn()}961 onThemeChange={vi.fn()}962 onAccentChange={vi.fn()}963 onTextSizeChange={vi.fn()}964 onReduceMotionChange={vi.fn()}965 shortcuts={DEFAULT_BINDINGS}966 onShortcutsChange={vi.fn()}967 onBack={vi.fn()}968 onImport={vi.fn()}969 onExport={vi.fn()}970 onBreachCheckDone={vi.fn()}971 />972 );973 await flushView();974975 expect(container.querySelectorAll(".stk-card").length).toBe(7);976 977 978 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");979980 const changeButton = Array.from(container.querySelectorAll("button")).find(981 (candidate) => candidate.textContent?.trim() === "change"982 ) as HTMLButtonElement;983 changeButton.click();984 await flushView();985986 const saveButton = Array.from(container.querySelectorAll("button")).find(987 (candidate) => candidate.textContent?.trim() === "save new password"988 ) as HTMLButtonElement;989 expect(saveButton.disabled).toBe(true);990991 const inputs = container.querySelectorAll('input[type="password"]');992 inputText(inputs[0] as HTMLInputElement, "current-pass");993 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");994 await flushView();995 expect(saveButton.disabled).toBe(true);996997 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");998 await flushView();999 expect(saveButton.disabled).toBe(false);1000 });10011002 it("Settings feedback highlights the selected category and hides the form after submit", async () => {1003 sendMessageMock.mockResolvedValue({ ok: true, data: null });1004 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1005 ok: true,1006 json: async () => ({ success: true }),1007 }));1008 vi.stubGlobal("fetch", fetchMock);10091010 const container = mount(1011 <SettingsView1012 entriesCount={1}1013 breachMonitoring={true}1014 lockOnClose={true}1015 autoLockMinutes={5}1016 trashRetentionDays={30}1017 theme="dark"1018 accentColor="peach"1019 textSize="medium"1020 reduceMotion={false}1021 initialSection="feedback"1022 onBreachMonitoringChange={vi.fn()}1023 onLockOnCloseChange={vi.fn()}1024 onAutoLockMinutesChange={vi.fn()}1025 onTrashRetentionChange={vi.fn()}1026 onThemeChange={vi.fn()}1027 onAccentChange={vi.fn()}1028 onTextSizeChange={vi.fn()}1029 onReduceMotionChange={vi.fn()}1030 shortcuts={DEFAULT_BINDINGS}1031 onShortcutsChange={vi.fn()}1032 onBack={vi.fn()}1033 onImport={vi.fn()}1034 onExport={vi.fn()}1035 onBreachCheckDone={vi.fn()}1036 />1037 );1038 await flushView();10391040 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1041 expect(feedbackCard.classList.contains("is-active")).toBe(true);10421043 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1044 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1045 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1046 expect(bugButton.getAttribute("aria-pressed")).toBe("true");1047 expect(featureButton.getAttribute("aria-pressed")).toBe("false");10481049 featureButton.click();1050 await flushView();10511052 expect(bugButton.getAttribute("aria-pressed")).toBe("false");1053 expect(featureButton.getAttribute("aria-pressed")).toBe("true");1054 expect(featureButton.classList.contains("is-active")).toBe(true);10551056 inputText(1057 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1058 '<script>alert("xss")</script> Please make this easier to see & safer.'1059 );1060 const sendButton = Array.from(container.querySelectorAll("button")).find(1061 (candidate) => candidate.textContent?.trim() === "Send"1062 ) as HTMLButtonElement;1063 sendButton.click();10641065 await vi.waitFor(() => {1066 expect(container.textContent).toContain("Thank you");1067 });10681069 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1070 expect(requestBody.subject).toContain("Feature request");1071 expect(requestBody.message).toBe(1072 "<script>alert("xss")</script> Please make this easier to see & safer."1073 );1074 expect(requestBody.message).not.toContain("<script>");1075 expect(container.textContent).toContain("Someone will process it soon");1076 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1077 expect(container.querySelector(".peach-feedback-submit")).toBeNull();1078 });10791080 it("Settings keeps license activation on the full-screen celebration path", async () => {1081 const onLicenseChange = vi.fn(async () => undefined);1082 sendMessageMock.mockImplementation(async (msg: unknown) => {1083 const type = (msg as { type?: string }).type;1084 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1085 if (type === "ACTIVATE_LICENSE") {1086 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1087 }1088 if (type === "GET_ENTRIES") return { ok: true, data: [] };1089 return { ok: true, data: null };1090 });10911092 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1093 await flushView();10941095 inputText(1096 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1097 "PEACH-TEST-KEY",1098 );1099 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1100 apply.click();11011102 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1103 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1104 expect(container.textContent).not.toContain("thank you ✦");1105 });11061107 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1108 sendMessageMock.mockImplementation(async (msg: unknown) => {1109 const type = (msg as { type?: string }).type;1110 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1111 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1112 if (type === "GET_ENTRIES") return { ok: true, data: [] };1113 return { ok: true, data: null };1114 });11151116 const onLicenseChangeFromStar = vi.fn();1117 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1118 await flushView();1119 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1120 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1121 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();11221123 const onLicenseChangeFromPeach = vi.fn();1124 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1125 await flushView();1126 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1127 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1128 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1129 });11301131 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1132 sendMessageMock.mockResolvedValue({ ok: true, data: null });1133 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1134 ok: true,1135 json: async () => ({ success: true }),1136 }));1137 vi.stubGlobal("fetch", fetchMock);1138 1139 1140 1141 vi.stubGlobal("browser", {1142 permissions: {1143 getAll: vi.fn(async () => ({ data_collection: [] })),1144 request: vi.fn(async () => false),1145 },1146 });11471148 const container = mount(1149 <SettingsView1150 entriesCount={1}1151 breachMonitoring={true}1152 lockOnClose={true}1153 autoLockMinutes={5}1154 trashRetentionDays={30}1155 theme="dark"1156 accentColor="peach"1157 textSize="medium"1158 reduceMotion={false}1159 initialSection="feedback"1160 onBreachMonitoringChange={vi.fn()}1161 onLockOnCloseChange={vi.fn()}1162 onAutoLockMinutesChange={vi.fn()}1163 onTrashRetentionChange={vi.fn()}1164 onThemeChange={vi.fn()}1165 onAccentChange={vi.fn()}1166 onTextSizeChange={vi.fn()}1167 onReduceMotionChange={vi.fn()}1168 shortcuts={DEFAULT_BINDINGS}1169 onShortcutsChange={vi.fn()}1170 onBack={vi.fn()}1171 onImport={vi.fn()}1172 onExport={vi.fn()}1173 onBreachCheckDone={vi.fn()}1174 />1175 );1176 await flushView();11771178 inputText(1179 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1180 "Please improve the feedback flow."1181 );1182 const sendButton = Array.from(container.querySelectorAll("button")).find(1183 (candidate) => candidate.textContent?.trim() === "Send"1184 ) as HTMLButtonElement;1185 sendButton.click();11861187 await vi.waitFor(() => {1188 expect(fetchMock).toHaveBeenCalledTimes(1);1189 });1190 await vi.waitFor(() => {1191 expect(container.textContent).toContain("Thank you");1192 });1193 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1194 });11951196 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1197 sendMessageMock.mockImplementation(async (msg: unknown) => {1198 const m = msg as { type: string };1199 if (m.type === "GET_RECOVERY_WORDS") {1200 return {1201 ok: true,1202 data: [1203 "abandon", "ability", "able", "about", "above", "absent",1204 "absorb", "abstract", "absurd", "abuse", "access", "accident",1205 ],1206 };1207 }1208 return { ok: true, data: null };1209 });1210 const onAutoLockMinutesChange = vi.fn();1211 const onThemeChange = vi.fn();1212 const container = mount(1213 <SettingsView1214 entriesCount={1}1215 breachMonitoring={true}1216 lockOnClose={true}1217 autoLockMinutes={5}1218 trashRetentionDays={30}1219 theme="dark"1220 accentColor="peach"1221 textSize="medium"1222 reduceMotion={false}1223 onBreachMonitoringChange={vi.fn()}1224 onLockOnCloseChange={vi.fn()}1225 onAutoLockMinutesChange={onAutoLockMinutesChange}1226 onTrashRetentionChange={vi.fn()}1227 onThemeChange={onThemeChange}1228 onAccentChange={vi.fn()}1229 onTextSizeChange={vi.fn()}1230 onReduceMotionChange={vi.fn()}1231 shortcuts={DEFAULT_BINDINGS}1232 onShortcutsChange={vi.fn()}1233 onBack={vi.fn()}1234 onImport={vi.fn()}1235 onExport={vi.fn()}1236 onBreachCheckDone={vi.fn()}1237 />1238 );1239 await flushView();1240 sendMessageMock.mockClear();12411242 1243 const selects = container.querySelectorAll("select");1244 changeSelect(selects[0] as HTMLSelectElement, "15");1245 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12461247 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1248 appearanceCard.click();1249 await flushView();1250 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1251 const themeButton = Array.from(container.querySelectorAll("button")).find(1252 (candidate) => candidate.textContent?.trim() === "Switch to light"1253 ) as HTMLButtonElement;1254 themeButton.click();1255 expect(onThemeChange).toHaveBeenCalledWith("light");12561257 1258 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1259 protectionCard.click();1260 await flushView();12611262 const changeButton = Array.from(container.querySelectorAll("button")).find(1263 (candidate) => candidate.textContent?.trim() === "change"1264 ) as HTMLButtonElement;1265 changeButton.click();1266 await flushView();12671268 const inputs = container.querySelectorAll('input[type="password"]');1269 inputText(inputs[0] as HTMLInputElement, "current-pass");1270 inputText(inputs[1] as HTMLInputElement, "new-password-123");1271 inputText(inputs[2] as HTMLInputElement, "new-password-123");1272 await flushView();12731274 const saveButton = Array.from(container.querySelectorAll("button")).find(1275 (candidate) => candidate.textContent?.trim() === "save new password"1276 ) as HTMLButtonElement;1277 saveButton.click();1278 await flushView();12791280 expect(sendMessageMock).toHaveBeenCalledWith({1281 type: "CHANGE_PASSWORD",1282 payload: {1283 currentPassword: "current-pass",1284 newPassword: "new-password-123",1285 },1286 });12871288 1289 expect(container.textContent).toContain("save your recovery words");1290 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1291 });12921293 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1294 sendMessageMock.mockResolvedValue({ ok: true });1295 const container = mount(1296 <EntryView1297 entry={sampleEntry()}1298 onDelete={vi.fn()}1299 onFieldSaved={vi.fn(async () => undefined)}1300 />1301 );13021303 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1304 passwordButton.click();1305 await flushView();13061307 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1308 type: "COPY",1309 payload: { text: "secret123", label: "password", alreadyWritten: false },1310 });1311 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1312 type: "FIRE_CANARY_PING",1313 payload: {1314 webhookUrl: "https://example.com/hook",1315 entryTitle: "GitHub",1316 trigger: "password_copied",1317 },1318 });13191320 sendMessageMock.mockClear();1321 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1322 usernameButton.click();1323 await flushView();13241325 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1326 type: "COPY",1327 payload: { text: "june", label: "username", alreadyWritten: false },1328 });1329 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1330 type: "FIRE_CANARY_PING",1331 payload: {1332 webhookUrl: "https://example.com/hook",1333 entryTitle: "GitHub",1334 trigger: "username_copied",1335 },1336 });1337 });13381339 it("Entry keyboard password copy reports its canary after COPY", async () => {1340 sendMessageMock.mockResolvedValue({ ok: true });1341 mount(1342 <EntryView1343 entry={sampleEntry()}1344 onDelete={vi.fn()}1345 onFieldSaved={vi.fn(async () => undefined)}1346 />1347 );13481349 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1350 await flushView();13511352 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1353 type: "COPY",1354 payload: { text: "secret123", label: "password", alreadyWritten: false },1355 });1356 await flushView();1357 expect(sendMessageMock.mock.calls).toContainEqual([{1358 type: "FIRE_CANARY_PING",1359 payload: {1360 webhookUrl: "https://example.com/hook",1361 entryTitle: "GitHub",1362 trigger: "password_copied",1363 },1364 }]);1365 });1366 it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1367 sendMessageMock.mockResolvedValue({ ok: true });1368 mount(1369 <EntryView1370 entry={sampleEntry()}1371 onDelete={vi.fn()}1372 onFieldSaved={vi.fn(async () => undefined)}1373 />1374 );13751376 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1377 await flushView();13781379 expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1380 });13811382 it("Vault password swipe copy reports its canary after COPY", async () => {1383 sendMessageMock.mockResolvedValue({ ok: true });1384 const container = mount(1385 <VaultView1386 entries={[sampleEntry()]}1387 sortOrder="url-first"1388 onSelectEntry={vi.fn()}1389 onSortOrderChange={vi.fn()}1390 onAdd={vi.fn()}1391 onClose={vi.fn()}1392 />1393 );1394 const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1395 row.dispatchEvent(new TouchEvent("touchstart", {1396 bubbles: true,1397 touches: [{ clientX: 0, clientY: 0 } as Touch],1398 }));1399 row.dispatchEvent(new TouchEvent("touchend", {1400 bubbles: true,1401 changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1402 }));1403 await flushView();14041405 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1406 type: "COPY",1407 payload: { text: "secret123", label: "password", alreadyWritten: false },1408 });1409 await flushView();1410 expect(sendMessageMock.mock.calls).toContainEqual([{1411 type: "FIRE_CANARY_PING",1412 payload: {1413 webhookUrl: "https://example.com/hook",1414 entryTitle: "GitHub",1415 trigger: "password_copied",1416 },1417 }]);1418 });14191420 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1421 const container = mount(1422 <EntryView1423 entry={sampleEntry({1424 type: "passkey",1425 username: undefined,1426 password: undefined,1427 totp: undefined,1428 canary: undefined,1429 passkey: {1430 rpId: "vercel.com",1431 rpName: "Vercel",1432 credentialId: "cred-1",1433 publicKeyCose: "cose",1434 userHandle: "handle",1435 userName: "june",1436 userDisplayName: "June",1437 algorithm: -7,1438 signCount: 0,1439 aaguid: "",1440 backupEligible: false,1441 backupState: false,1442 createdAt: 1,1443 },1444 })}1445 onDelete={vi.fn()}1446 onFieldSaved={vi.fn(async () => undefined)}1447 />1448 );14491450 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1451 (button) => button.textContent?.includes("Advanced")1452 ) as HTMLButtonElement | undefined;1453 expect(advanced).toBeDefined();1454 expect(advanced?.textContent).toContain("+ Advanced");14551456 advanced!.click();1457 await flushView();14581459 expect(container.textContent).toContain("passkey · vercel.com");1460 });14611462 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1463 sendMessageMock.mockResolvedValue({ ok: true });1464 const onFieldSaved = vi.fn(async () => undefined);1465 const container = mount(1466 <EntryView1467 entry={sampleEntry()}1468 onDelete={vi.fn()}1469 onFieldSaved={onFieldSaved}1470 />1471 );14721473 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1474 passwordValue.click();1475 await flushView();14761477 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1478 inputText(passwordInput, "");1479 await flushView();14801481 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1482 saveButton.click();1483 await flushView();14841485 expect(sendMessageMock).not.toHaveBeenCalledWith(1486 expect.objectContaining({ type: "UPDATE_ENTRY" })1487 );1488 expect(onFieldSaved).not.toHaveBeenCalled();14891490 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1491 totpMain.click();14921493 await vi.waitFor(() => {1494 expect(sendMessageMock).toHaveBeenLastCalledWith({1495 type: "COPY",1496 payload: { text: "123456", label: "totp", alreadyWritten: false },1497 });1498 }, { timeout: 500 });1499 });15001501 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1502 sendMessageMock.mockResolvedValue({ ok: true });1503 const container = mount(1504 <EntryView1505 entry={sampleEntry()}1506 onDelete={vi.fn()}1507 onFieldSaved={vi.fn(async () => undefined)}1508 />1509 );15101511 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1512 usernameValue.click();1513 await flushView();15141515 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1516 usernameInput.blur();1517 await flushView();15181519 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1520 expect(sendMessageMock).not.toHaveBeenCalledWith(1521 expect.objectContaining({ type: "UPDATE_ENTRY" })1522 );1523 });15241525 it("Entry keeps the full used-email list available while editing an email", async () => {1526 const container = mount(1527 <EntryView1528 entry={sampleEntry({ email: "[email protected]" })}1529 entries={[1530 sampleEntry({ email: "[email protected]" }),1531 sampleEntry({ id: "entry-2", email: "[email protected]" }),1532 sampleEntry({ id: "entry-3", username: "duckuccino" }),1533 ]}1534 onDelete={vi.fn()}1535 onFieldSaved={vi.fn(async () => undefined)}1536 />1537 );15381539 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1540 (card) => card.querySelector(".peach-label")?.textContent === "email"1541 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1542 emailValue.click();1543 await flushView();15441545 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1546 .map((element) => element.textContent);1547 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1548 });15491550 it("Entry lets TOTP secrets be edited inline", async () => {1551 sendMessageMock.mockResolvedValue({ ok: true });1552 const onFieldSaved = vi.fn(async () => undefined);1553 const container = mount(1554 <EntryView1555 entry={sampleEntry()}1556 onDelete={vi.fn()}1557 onFieldSaved={onFieldSaved}1558 />1559 );15601561 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1562 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1563 await flushView();15641565 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1566 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1567 await flushView();15681569 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1570 saveButton.click();1571 await flushView();15721573 expect(sendMessageMock).toHaveBeenCalledWith({1574 type: "UPDATE_ENTRY",1575 payload: {1576 id: "entry-1",1577 patch: { totp: "NB2W45DFOIZA====" },1578 },1579 });1580 expect(onFieldSaved).toHaveBeenCalled();1581 });15821583 it("Vault seeds and focuses search when a search seed is provided", async () => {1584 const container = mount(1585 <VaultView1586 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1587 sortOrder="url-first"1588 searchSeed="g"1589 searchSeedNonce={1}1590 onSelectEntry={vi.fn()}1591 onSortOrderChange={vi.fn()}1592 onAdd={vi.fn()}1593 onClose={vi.fn()}1594 />1595 );15961597 await flushView();15981599 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1600 expect(search.value).toBe("g");1601 expect(document.activeElement).toBe(search);1602 });16031604 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1605 parseImportSourceMock.mockResolvedValue(1606 sampleImportResult({1607 entries: [1608 {1609 type: "login",1610 title: "Imported site",1611 username: "imported-user",1612 password: "imported-pass",1613 url: "https://imported.test",1614 },1615 ],1616 })1617 );1618 sendMessageMock.mockImplementation(async (msg: unknown) => {1619 const type = (msg as { type?: string }).type;1620 if (type === "GET_ENTRIES") {1621 return {1622 ok: true,1623 data: [1624 sampleEntry({1625 id: "existing-1",1626 title: "Imported site",1627 username: "imported-user",1628 url: "https://imported.test",1629 }),1630 ],1631 };1632 }1633 if (type === "BATCH_ADD_ENTRIES") {1634 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1635 }1636 return { ok: true };1637 });1638 const onImport = vi.fn(async () => undefined);1639 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1640 await flushView();1641 sendMessageMock.mockClear();16421643 await selectImportFile(container);1644 findButton(container, "Import 1").click();1645 await flushView();16461647 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1648 expect(sendMessageMock).toHaveBeenCalledWith({1649 type: "BATCH_ADD_ENTRIES",1650 payload: {1651 entries: [1652 expect.objectContaining({1653 title: "Imported site",1654 username: "imported-user",1655 password: "imported-pass",1656 url: "https://imported.test",1657 }),1658 ],1659 },1660 });1661 expect(onImport).toHaveBeenCalled();1662 expect(container.textContent).toContain("1 entry appears to be duplicates");1663 });16641665 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1666 parseImportSourceMock.mockResolvedValue(sampleImportResult());1667 sendMessageMock.mockImplementation(async (msg: unknown) => {1668 const type = (msg as { type?: string }).type;1669 if (type === "GET_ENTRIES") {1670 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1671 }1672 if (type === "CLEAR_VAULT_ENTRIES") {1673 return { ok: true };1674 }1675 if (type === "BATCH_ADD_ENTRIES") {1676 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1677 }1678 return { ok: true };1679 });1680 const onImport = vi.fn(async () => undefined);1681 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1682 await flushView();1683 sendMessageMock.mockClear();16841685 await selectImportFile(container);1686 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1687 await flushView();16881689 findButton(container, "Replace vault with 1").click();1690 await flushView();16911692 expect(sendMessageMock).not.toHaveBeenCalledWith(1693 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1694 );1695 expect(container.textContent).toContain("Replace forever? This cannot be undone.");16961697 findButton(container, "Yes, replace forever").click();1698 await flushView();16991700 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1701 expect(sendMessageMock).toHaveBeenCalledWith({1702 type: "BATCH_ADD_ENTRIES",1703 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1704 });1705 expect(onImport).toHaveBeenCalled();1706 });17071708 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1709 parseImportSourceMock.mockResolvedValue(sampleImportResult());1710 sendMessageMock.mockImplementation(async (msg: unknown) => {1711 const type = (msg as { type?: string }).type;1712 if (type === "CREATE_VAULT") return { ok: true };1713 if (type === "BATCH_ADD_ENTRIES") {1714 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1715 }1716 return { ok: true };1717 });1718 const onImport = vi.fn(async () => undefined);1719 const container = mount(1720 <ImportView1721 mode="onboarding"1722 replacesExistingVault={true}1723 onBack={vi.fn()}1724 onImport={onImport}1725 />1726 );17271728 await selectImportFile(container);17291730 const createButton = findButton(container, "Create vault + import 1");1731 expect(createButton.disabled).toBe(true);1732 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1733 expect(container.textContent).toContain("Use at least 8 characters");17341735 const masterInput = container.querySelector(1736 'input[placeholder="new master password"]'1737 ) as HTMLInputElement;1738 const confirmInput = container.querySelector(1739 'input[placeholder="confirm master password"]'1740 ) as HTMLInputElement;1741 const revealButton = container.querySelector(1742 'button[aria-label="Show passwords"]'1743 ) as HTMLButtonElement;1744 expect(revealButton).toBeTruthy();1745 revealButton.click();1746 await flushView();1747 expect(masterInput.type).toBe("text");1748 expect(confirmInput.type).toBe("text");17491750 inputText(masterInput, "fresh-master-password");1751 inputText(confirmInput, "fresh-master-password");1752 await flushView();17531754 expect(createButton.disabled).toBe(false);1755 createButton.click();1756 await flushView();17571758 expect(sendMessageMock).toHaveBeenCalledWith({1759 type: "CREATE_VAULT",1760 payload: { password: "fresh-master-password" },1761 });1762 expect(sendMessageMock).toHaveBeenCalledWith({1763 type: "BATCH_ADD_ENTRIES",1764 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1765 });1766 expect(onImport).toHaveBeenCalled();1767 });17681769});