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 labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));815 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;816 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;817 expect(818 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,819 ).toBeTruthy();820 expect(821 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,822 ).toBeTruthy();823824 Array.from(container.querySelectorAll("button"))825 .find((b) => (b.textContent ?? "").trim() === "remove")!826 .click();827 for (let i = 0; i < 10; i++) await Promise.resolve();828 const confirm = Array.from(829 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),830 ).find((b) => (b.textContent ?? "").trim() === "remove");831 confirm!.click();832 for (let i = 0; i < 30; i++) await Promise.resolve();833834 expect(sendMessageMock).toHaveBeenCalledWith({835 type: "UNPAIR_DEVICE",836 payload: { deviceId: "phone-1" },837 });838 839 840 841 expect(container.textContent).not.toContain("June's Phone");842 expect(container.textContent).not.toContain("PAIRED DEVICES");843 });844 it("Settings keeps the confirmation and shows revoke failures", async () => {845 const device = {846 deviceId: "phone-1",847 deviceName: "June's Phone",848 deviceType: "android" as const,849 longTermPublicKeyHex: "a".repeat(64),850 signingPublicKeyHex: "b".repeat(64),851 pairedAt: 1,852 trustSource: "direct" as const,853 };854 sendMessageMock.mockImplementation(async (msg: unknown) => {855 const message = msg as { type?: string; payload?: { deviceId?: string } };856 if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };857 if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };858 return { ok: true, data: null };859 });860861 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);862 for (let i = 0; i < 30; i++) await Promise.resolve();863 for (let i = 0; i < 30; i++) await Promise.resolve();864 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();865 for (let i = 0; i < 30; i++) await Promise.resolve();866 Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();867 for (let i = 0; i < 10; i++) await Promise.resolve();868 Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();869 for (let i = 0; i < 30; i++) await Promise.resolve();870871 expect(container.textContent).toContain("trust_data_corrupted");872 expect(container.textContent).toContain("June's Phone");873 });874875 it("Settings drops the secure-reset panel once the device is on the settled generation", async () => {876 const openSyncCard = async (state: "V2_ACTIVE" | "V2_ANCHOR" | "LEGACY_ACTIVE") => {877 sendMessageMock.mockImplementation(async (msg: unknown) => {878 const message = msg as { type?: string };879 if (message.type === "GET_SYNC_GENERATION") {880 return { ok: true, data: { schemaVersion: 1, generation: 2, state, createdAt: 1 } };881 }882 return { ok: true, data: null };883 });884 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);885 for (let i = 0; i < 30; i++) await Promise.resolve();886 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();887 for (let i = 0; i < 30; i++) await Promise.resolve();888 return container;889 };890891 892 const settled = await openSyncCard("V2_ACTIVE");893 expect(settled.textContent).not.toContain("SECURE SYNC RESET");894 expect(settled.textContent).toContain("ADD A DEVICE");895896 897 const anchored = await openSyncCard("V2_ANCHOR");898 expect(anchored.textContent).toContain("SECURE SYNC RESET");899 expect(anchored.textContent).toContain("Not re-paired");900901 const legacy = await openSyncCard("LEGACY_ACTIVE");902 expect(legacy.textContent).toContain("SECURE SYNC RESET");903 expect(legacy.textContent).toContain("Legacy Sync generation");904 });905906 it("Settings keeps connection and recovery details collapsed until requested", async () => {907 sendMessageMock.mockImplementation(async (msg: any) => {908 if (msg?.type === "LIST_PAIRED_DEVICES") {909 return { ok: true, data: { devices: [] } };910 }911 return { ok: true, data: null };912 });913914 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);915 for (let i = 0; i < 30; i++) await Promise.resolve();916 Array.from(container.querySelectorAll("button"))917 .find((button) => /sync/i.test(button.textContent ?? ""))918 ?.click();919 for (let i = 0; i < 20; i++) await Promise.resolve();920921 expect(container.textContent).toContain("SYNC STATUS");922 expect(container.textContent).toContain("ADD A DEVICE");923 expect(container.textContent).not.toContain("Public relay");924 expect(container.textContent).not.toContain("Rotate sync identity");925926 const connection = Array.from(container.querySelectorAll("button"))927 .find((button) => button.textContent?.includes("CONNECTION"));928 connection?.click();929 await Promise.resolve();930 expect(container.textContent).toContain("Public relay");931932 const recovery = Array.from(container.querySelectorAll("button"))933 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));934 recovery?.click();935 await Promise.resolve();936 expect(container.textContent).toContain("Rotate sync identity");937 });938939 it("Settings requires current, new, and confirm passwords before enabling save", async () => {940 sendMessageMock.mockResolvedValue({ ok: true, data: null });941 const container = mount(942 <SettingsView943 entriesCount={1}944 breachMonitoring={true}945 lockOnClose={true}946 autoLockMinutes={5}947 trashRetentionDays={30}948 theme="dark"949 accentColor="peach"950 textSize="medium"951 reduceMotion={false}952 onBreachMonitoringChange={vi.fn()}953 onLockOnCloseChange={vi.fn()}954 onAutoLockMinutesChange={vi.fn()}955 onTrashRetentionChange={vi.fn()}956 onThemeChange={vi.fn()}957 onAccentChange={vi.fn()}958 onTextSizeChange={vi.fn()}959 onReduceMotionChange={vi.fn()}960 shortcuts={DEFAULT_BINDINGS}961 onShortcutsChange={vi.fn()}962 onBack={vi.fn()}963 onImport={vi.fn()}964 onExport={vi.fn()}965 onBreachCheckDone={vi.fn()}966 />967 );968 await flushView();969970 expect(container.querySelectorAll(".stk-card").length).toBe(7);971 972 973 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");974975 const changeButton = Array.from(container.querySelectorAll("button")).find(976 (candidate) => candidate.textContent?.trim() === "change"977 ) as HTMLButtonElement;978 changeButton.click();979 await flushView();980981 const saveButton = Array.from(container.querySelectorAll("button")).find(982 (candidate) => candidate.textContent?.trim() === "save new password"983 ) as HTMLButtonElement;984 expect(saveButton.disabled).toBe(true);985986 const inputs = container.querySelectorAll('input[type="password"]');987 inputText(inputs[0] as HTMLInputElement, "current-pass");988 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");989 await flushView();990 expect(saveButton.disabled).toBe(true);991992 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");993 await flushView();994 expect(saveButton.disabled).toBe(false);995 });996997 it("Settings feedback highlights the selected category and hides the form after submit", async () => {998 sendMessageMock.mockResolvedValue({ ok: true, data: null });999 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1000 ok: true,1001 json: async () => ({ success: true }),1002 }));1003 vi.stubGlobal("fetch", fetchMock);10041005 const container = mount(1006 <SettingsView1007 entriesCount={1}1008 breachMonitoring={true}1009 lockOnClose={true}1010 autoLockMinutes={5}1011 trashRetentionDays={30}1012 theme="dark"1013 accentColor="peach"1014 textSize="medium"1015 reduceMotion={false}1016 initialSection="feedback"1017 onBreachMonitoringChange={vi.fn()}1018 onLockOnCloseChange={vi.fn()}1019 onAutoLockMinutesChange={vi.fn()}1020 onTrashRetentionChange={vi.fn()}1021 onThemeChange={vi.fn()}1022 onAccentChange={vi.fn()}1023 onTextSizeChange={vi.fn()}1024 onReduceMotionChange={vi.fn()}1025 shortcuts={DEFAULT_BINDINGS}1026 onShortcutsChange={vi.fn()}1027 onBack={vi.fn()}1028 onImport={vi.fn()}1029 onExport={vi.fn()}1030 onBreachCheckDone={vi.fn()}1031 />1032 );1033 await flushView();10341035 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1036 expect(feedbackCard.classList.contains("is-active")).toBe(true);10371038 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1039 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1040 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1041 expect(bugButton.getAttribute("aria-pressed")).toBe("true");1042 expect(featureButton.getAttribute("aria-pressed")).toBe("false");10431044 featureButton.click();1045 await flushView();10461047 expect(bugButton.getAttribute("aria-pressed")).toBe("false");1048 expect(featureButton.getAttribute("aria-pressed")).toBe("true");1049 expect(featureButton.classList.contains("is-active")).toBe(true);10501051 inputText(1052 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1053 '<script>alert("xss")</script> Please make this easier to see & safer.'1054 );1055 const sendButton = Array.from(container.querySelectorAll("button")).find(1056 (candidate) => candidate.textContent?.trim() === "Send"1057 ) as HTMLButtonElement;1058 sendButton.click();10591060 await vi.waitFor(() => {1061 expect(container.textContent).toContain("Thank you");1062 });10631064 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1065 expect(requestBody.subject).toContain("Feature request");1066 expect(requestBody.message).toBe(1067 "<script>alert("xss")</script> Please make this easier to see & safer."1068 );1069 expect(requestBody.message).not.toContain("<script>");1070 expect(container.textContent).toContain("Someone will process it soon");1071 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1072 expect(container.querySelector(".peach-feedback-submit")).toBeNull();1073 });10741075 it("Settings keeps license activation on the full-screen celebration path", async () => {1076 const onLicenseChange = vi.fn(async () => undefined);1077 sendMessageMock.mockImplementation(async (msg: unknown) => {1078 const type = (msg as { type?: string }).type;1079 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1080 if (type === "ACTIVATE_LICENSE") {1081 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1082 }1083 if (type === "GET_ENTRIES") return { ok: true, data: [] };1084 return { ok: true, data: null };1085 });10861087 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1088 await flushView();10891090 inputText(1091 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1092 "PEACH-TEST-KEY",1093 );1094 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1095 apply.click();10961097 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1098 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1099 expect(container.textContent).not.toContain("thank you ✦");1100 });11011102 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1103 sendMessageMock.mockImplementation(async (msg: unknown) => {1104 const type = (msg as { type?: string }).type;1105 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1106 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1107 if (type === "GET_ENTRIES") return { ok: true, data: [] };1108 return { ok: true, data: null };1109 });11101111 const onLicenseChangeFromStar = vi.fn();1112 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1113 await flushView();1114 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1115 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1116 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();11171118 const onLicenseChangeFromPeach = vi.fn();1119 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1120 await flushView();1121 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1122 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1123 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1124 });11251126 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1127 sendMessageMock.mockResolvedValue({ ok: true, data: null });1128 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1129 ok: true,1130 json: async () => ({ success: true }),1131 }));1132 vi.stubGlobal("fetch", fetchMock);1133 1134 1135 1136 vi.stubGlobal("browser", {1137 permissions: {1138 getAll: vi.fn(async () => ({ data_collection: [] })),1139 request: vi.fn(async () => false),1140 },1141 });11421143 const container = mount(1144 <SettingsView1145 entriesCount={1}1146 breachMonitoring={true}1147 lockOnClose={true}1148 autoLockMinutes={5}1149 trashRetentionDays={30}1150 theme="dark"1151 accentColor="peach"1152 textSize="medium"1153 reduceMotion={false}1154 initialSection="feedback"1155 onBreachMonitoringChange={vi.fn()}1156 onLockOnCloseChange={vi.fn()}1157 onAutoLockMinutesChange={vi.fn()}1158 onTrashRetentionChange={vi.fn()}1159 onThemeChange={vi.fn()}1160 onAccentChange={vi.fn()}1161 onTextSizeChange={vi.fn()}1162 onReduceMotionChange={vi.fn()}1163 shortcuts={DEFAULT_BINDINGS}1164 onShortcutsChange={vi.fn()}1165 onBack={vi.fn()}1166 onImport={vi.fn()}1167 onExport={vi.fn()}1168 onBreachCheckDone={vi.fn()}1169 />1170 );1171 await flushView();11721173 inputText(1174 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1175 "Please improve the feedback flow."1176 );1177 const sendButton = Array.from(container.querySelectorAll("button")).find(1178 (candidate) => candidate.textContent?.trim() === "Send"1179 ) as HTMLButtonElement;1180 sendButton.click();11811182 await vi.waitFor(() => {1183 expect(fetchMock).toHaveBeenCalledTimes(1);1184 });1185 await vi.waitFor(() => {1186 expect(container.textContent).toContain("Thank you");1187 });1188 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1189 });11901191 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1192 sendMessageMock.mockImplementation(async (msg: unknown) => {1193 const m = msg as { type: string };1194 if (m.type === "GET_RECOVERY_WORDS") {1195 return {1196 ok: true,1197 data: [1198 "abandon", "ability", "able", "about", "above", "absent",1199 "absorb", "abstract", "absurd", "abuse", "access", "accident",1200 ],1201 };1202 }1203 return { ok: true, data: null };1204 });1205 const onAutoLockMinutesChange = vi.fn();1206 const onThemeChange = vi.fn();1207 const container = mount(1208 <SettingsView1209 entriesCount={1}1210 breachMonitoring={true}1211 lockOnClose={true}1212 autoLockMinutes={5}1213 trashRetentionDays={30}1214 theme="dark"1215 accentColor="peach"1216 textSize="medium"1217 reduceMotion={false}1218 onBreachMonitoringChange={vi.fn()}1219 onLockOnCloseChange={vi.fn()}1220 onAutoLockMinutesChange={onAutoLockMinutesChange}1221 onTrashRetentionChange={vi.fn()}1222 onThemeChange={onThemeChange}1223 onAccentChange={vi.fn()}1224 onTextSizeChange={vi.fn()}1225 onReduceMotionChange={vi.fn()}1226 shortcuts={DEFAULT_BINDINGS}1227 onShortcutsChange={vi.fn()}1228 onBack={vi.fn()}1229 onImport={vi.fn()}1230 onExport={vi.fn()}1231 onBreachCheckDone={vi.fn()}1232 />1233 );1234 await flushView();1235 sendMessageMock.mockClear();12361237 1238 const selects = container.querySelectorAll("select");1239 changeSelect(selects[0] as HTMLSelectElement, "15");1240 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12411242 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1243 appearanceCard.click();1244 await flushView();1245 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1246 const themeButton = Array.from(container.querySelectorAll("button")).find(1247 (candidate) => candidate.textContent?.trim() === "Switch to light"1248 ) as HTMLButtonElement;1249 themeButton.click();1250 expect(onThemeChange).toHaveBeenCalledWith("light");12511252 1253 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1254 protectionCard.click();1255 await flushView();12561257 const changeButton = Array.from(container.querySelectorAll("button")).find(1258 (candidate) => candidate.textContent?.trim() === "change"1259 ) as HTMLButtonElement;1260 changeButton.click();1261 await flushView();12621263 const inputs = container.querySelectorAll('input[type="password"]');1264 inputText(inputs[0] as HTMLInputElement, "current-pass");1265 inputText(inputs[1] as HTMLInputElement, "new-password-123");1266 inputText(inputs[2] as HTMLInputElement, "new-password-123");1267 await flushView();12681269 const saveButton = Array.from(container.querySelectorAll("button")).find(1270 (candidate) => candidate.textContent?.trim() === "save new password"1271 ) as HTMLButtonElement;1272 saveButton.click();1273 await flushView();12741275 expect(sendMessageMock).toHaveBeenCalledWith({1276 type: "CHANGE_PASSWORD",1277 payload: {1278 currentPassword: "current-pass",1279 newPassword: "new-password-123",1280 },1281 });12821283 1284 expect(container.textContent).toContain("save your recovery words");1285 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1286 });12871288 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1289 sendMessageMock.mockResolvedValue({ ok: true });1290 const container = mount(1291 <EntryView1292 entry={sampleEntry()}1293 onDelete={vi.fn()}1294 onFieldSaved={vi.fn(async () => undefined)}1295 />1296 );12971298 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1299 passwordButton.click();1300 await flushView();13011302 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1303 type: "COPY",1304 payload: { text: "secret123", label: "password", alreadyWritten: false },1305 });1306 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1307 type: "FIRE_CANARY_PING",1308 payload: {1309 webhookUrl: "https://example.com/hook",1310 entryTitle: "GitHub",1311 trigger: "password_copied",1312 },1313 });13141315 sendMessageMock.mockClear();1316 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1317 usernameButton.click();1318 await flushView();13191320 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1321 type: "COPY",1322 payload: { text: "june", label: "username", alreadyWritten: false },1323 });1324 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1325 type: "FIRE_CANARY_PING",1326 payload: {1327 webhookUrl: "https://example.com/hook",1328 entryTitle: "GitHub",1329 trigger: "username_copied",1330 },1331 });1332 });13331334 it("Entry keyboard password copy reports its canary after COPY", async () => {1335 sendMessageMock.mockResolvedValue({ ok: true });1336 mount(1337 <EntryView1338 entry={sampleEntry()}1339 onDelete={vi.fn()}1340 onFieldSaved={vi.fn(async () => undefined)}1341 />1342 );13431344 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1345 await flushView();13461347 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1348 type: "COPY",1349 payload: { text: "secret123", label: "password", alreadyWritten: false },1350 });1351 await flushView();1352 expect(sendMessageMock.mock.calls).toContainEqual([{1353 type: "FIRE_CANARY_PING",1354 payload: {1355 webhookUrl: "https://example.com/hook",1356 entryTitle: "GitHub",1357 trigger: "password_copied",1358 },1359 }]);1360 });1361 it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1362 sendMessageMock.mockResolvedValue({ ok: true });1363 mount(1364 <EntryView1365 entry={sampleEntry()}1366 onDelete={vi.fn()}1367 onFieldSaved={vi.fn(async () => undefined)}1368 />1369 );13701371 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1372 await flushView();13731374 expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1375 });13761377 it("Vault password swipe copy reports its canary after COPY", async () => {1378 sendMessageMock.mockResolvedValue({ ok: true });1379 const container = mount(1380 <VaultView1381 entries={[sampleEntry()]}1382 sortOrder="url-first"1383 onSelectEntry={vi.fn()}1384 onSortOrderChange={vi.fn()}1385 onAdd={vi.fn()}1386 onClose={vi.fn()}1387 />1388 );1389 const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1390 row.dispatchEvent(new TouchEvent("touchstart", {1391 bubbles: true,1392 touches: [{ clientX: 0, clientY: 0 } as Touch],1393 }));1394 row.dispatchEvent(new TouchEvent("touchend", {1395 bubbles: true,1396 changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1397 }));1398 await flushView();13991400 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1401 type: "COPY",1402 payload: { text: "secret123", label: "password", alreadyWritten: false },1403 });1404 await flushView();1405 expect(sendMessageMock.mock.calls).toContainEqual([{1406 type: "FIRE_CANARY_PING",1407 payload: {1408 webhookUrl: "https://example.com/hook",1409 entryTitle: "GitHub",1410 trigger: "password_copied",1411 },1412 }]);1413 });14141415 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1416 const container = mount(1417 <EntryView1418 entry={sampleEntry({1419 type: "passkey",1420 username: undefined,1421 password: undefined,1422 totp: undefined,1423 canary: undefined,1424 passkey: {1425 rpId: "vercel.com",1426 rpName: "Vercel",1427 credentialId: "cred-1",1428 publicKeyCose: "cose",1429 userHandle: "handle",1430 userName: "june",1431 userDisplayName: "June",1432 algorithm: -7,1433 signCount: 0,1434 aaguid: "",1435 backupEligible: false,1436 backupState: false,1437 createdAt: 1,1438 },1439 })}1440 onDelete={vi.fn()}1441 onFieldSaved={vi.fn(async () => undefined)}1442 />1443 );14441445 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1446 (button) => button.textContent?.includes("Advanced")1447 ) as HTMLButtonElement | undefined;1448 expect(advanced).toBeDefined();1449 expect(advanced?.textContent).toContain("+ Advanced");14501451 advanced!.click();1452 await flushView();14531454 expect(container.textContent).toContain("passkey · vercel.com");1455 });14561457 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1458 sendMessageMock.mockResolvedValue({ ok: true });1459 const onFieldSaved = vi.fn(async () => undefined);1460 const container = mount(1461 <EntryView1462 entry={sampleEntry()}1463 onDelete={vi.fn()}1464 onFieldSaved={onFieldSaved}1465 />1466 );14671468 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1469 passwordValue.click();1470 await flushView();14711472 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1473 inputText(passwordInput, "");1474 await flushView();14751476 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1477 saveButton.click();1478 await flushView();14791480 expect(sendMessageMock).not.toHaveBeenCalledWith(1481 expect.objectContaining({ type: "UPDATE_ENTRY" })1482 );1483 expect(onFieldSaved).not.toHaveBeenCalled();14841485 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1486 totpMain.click();14871488 await vi.waitFor(() => {1489 expect(sendMessageMock).toHaveBeenLastCalledWith({1490 type: "COPY",1491 payload: { text: "123456", label: "totp", alreadyWritten: false },1492 });1493 }, { timeout: 500 });1494 });14951496 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1497 sendMessageMock.mockResolvedValue({ ok: true });1498 const container = mount(1499 <EntryView1500 entry={sampleEntry()}1501 onDelete={vi.fn()}1502 onFieldSaved={vi.fn(async () => undefined)}1503 />1504 );15051506 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1507 usernameValue.click();1508 await flushView();15091510 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1511 usernameInput.blur();1512 await flushView();15131514 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1515 expect(sendMessageMock).not.toHaveBeenCalledWith(1516 expect.objectContaining({ type: "UPDATE_ENTRY" })1517 );1518 });15191520 it("Entry keeps the full used-email list available while editing an email", async () => {1521 const container = mount(1522 <EntryView1523 entry={sampleEntry({ email: "[email protected]" })}1524 entries={[1525 sampleEntry({ email: "[email protected]" }),1526 sampleEntry({ id: "entry-2", email: "[email protected]" }),1527 sampleEntry({ id: "entry-3", username: "duckuccino" }),1528 ]}1529 onDelete={vi.fn()}1530 onFieldSaved={vi.fn(async () => undefined)}1531 />1532 );15331534 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1535 (card) => card.querySelector(".peach-label")?.textContent === "email"1536 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1537 emailValue.click();1538 await flushView();15391540 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1541 .map((element) => element.textContent);1542 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1543 });15441545 it("Entry lets TOTP secrets be edited inline", async () => {1546 sendMessageMock.mockResolvedValue({ ok: true });1547 const onFieldSaved = vi.fn(async () => undefined);1548 const container = mount(1549 <EntryView1550 entry={sampleEntry()}1551 onDelete={vi.fn()}1552 onFieldSaved={onFieldSaved}1553 />1554 );15551556 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1557 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1558 await flushView();15591560 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1561 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1562 await flushView();15631564 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1565 saveButton.click();1566 await flushView();15671568 expect(sendMessageMock).toHaveBeenCalledWith({1569 type: "UPDATE_ENTRY",1570 payload: {1571 id: "entry-1",1572 patch: { totp: "NB2W45DFOIZA====" },1573 },1574 });1575 expect(onFieldSaved).toHaveBeenCalled();1576 });15771578 it("Vault seeds and focuses search when a search seed is provided", async () => {1579 const container = mount(1580 <VaultView1581 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1582 sortOrder="url-first"1583 searchSeed="g"1584 searchSeedNonce={1}1585 onSelectEntry={vi.fn()}1586 onSortOrderChange={vi.fn()}1587 onAdd={vi.fn()}1588 onClose={vi.fn()}1589 />1590 );15911592 await flushView();15931594 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1595 expect(search.value).toBe("g");1596 expect(document.activeElement).toBe(search);1597 });15981599 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1600 parseImportSourceMock.mockResolvedValue(1601 sampleImportResult({1602 entries: [1603 {1604 type: "login",1605 title: "Imported site",1606 username: "imported-user",1607 password: "imported-pass",1608 url: "https://imported.test",1609 },1610 ],1611 })1612 );1613 sendMessageMock.mockImplementation(async (msg: unknown) => {1614 const type = (msg as { type?: string }).type;1615 if (type === "GET_ENTRIES") {1616 return {1617 ok: true,1618 data: [1619 sampleEntry({1620 id: "existing-1",1621 title: "Imported site",1622 username: "imported-user",1623 url: "https://imported.test",1624 }),1625 ],1626 };1627 }1628 if (type === "BATCH_ADD_ENTRIES") {1629 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1630 }1631 return { ok: true };1632 });1633 const onImport = vi.fn(async () => undefined);1634 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1635 await flushView();1636 sendMessageMock.mockClear();16371638 await selectImportFile(container);1639 findButton(container, "Import 1").click();1640 await flushView();16411642 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1643 expect(sendMessageMock).toHaveBeenCalledWith({1644 type: "BATCH_ADD_ENTRIES",1645 payload: {1646 entries: [1647 expect.objectContaining({1648 title: "Imported site",1649 username: "imported-user",1650 password: "imported-pass",1651 url: "https://imported.test",1652 }),1653 ],1654 },1655 });1656 expect(onImport).toHaveBeenCalled();1657 expect(container.textContent).toContain("1 entry appears to be duplicates");1658 });16591660 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1661 parseImportSourceMock.mockResolvedValue(sampleImportResult());1662 sendMessageMock.mockImplementation(async (msg: unknown) => {1663 const type = (msg as { type?: string }).type;1664 if (type === "GET_ENTRIES") {1665 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1666 }1667 if (type === "CLEAR_VAULT_ENTRIES") {1668 return { ok: true };1669 }1670 if (type === "BATCH_ADD_ENTRIES") {1671 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1672 }1673 return { ok: true };1674 });1675 const onImport = vi.fn(async () => undefined);1676 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1677 await flushView();1678 sendMessageMock.mockClear();16791680 await selectImportFile(container);1681 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1682 await flushView();16831684 findButton(container, "Replace vault with 1").click();1685 await flushView();16861687 expect(sendMessageMock).not.toHaveBeenCalledWith(1688 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1689 );1690 expect(container.textContent).toContain("Replace forever? This cannot be undone.");16911692 findButton(container, "Yes, replace forever").click();1693 await flushView();16941695 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1696 expect(sendMessageMock).toHaveBeenCalledWith({1697 type: "BATCH_ADD_ENTRIES",1698 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1699 });1700 expect(onImport).toHaveBeenCalled();1701 });17021703 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1704 parseImportSourceMock.mockResolvedValue(sampleImportResult());1705 sendMessageMock.mockImplementation(async (msg: unknown) => {1706 const type = (msg as { type?: string }).type;1707 if (type === "CREATE_VAULT") return { ok: true };1708 if (type === "BATCH_ADD_ENTRIES") {1709 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1710 }1711 return { ok: true };1712 });1713 const onImport = vi.fn(async () => undefined);1714 const container = mount(1715 <ImportView1716 mode="onboarding"1717 replacesExistingVault={true}1718 onBack={vi.fn()}1719 onImport={onImport}1720 />1721 );17221723 await selectImportFile(container);17241725 const createButton = findButton(container, "Create vault + import 1");1726 expect(createButton.disabled).toBe(true);1727 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1728 expect(container.textContent).toContain("Use at least 8 characters");17291730 const masterInput = container.querySelector(1731 'input[placeholder="new master password"]'1732 ) as HTMLInputElement;1733 const confirmInput = container.querySelector(1734 'input[placeholder="confirm master password"]'1735 ) as HTMLInputElement;1736 const revealButton = container.querySelector(1737 'button[aria-label="Show passwords"]'1738 ) as HTMLButtonElement;1739 expect(revealButton).toBeTruthy();1740 revealButton.click();1741 await flushView();1742 expect(masterInput.type).toBe("text");1743 expect(confirmInput.type).toBe("text");17441745 inputText(masterInput, "fresh-master-password");1746 inputText(confirmInput, "fresh-master-password");1747 await flushView();17481749 expect(createButton.disabled).toBe(false);1750 createButton.click();1751 await flushView();17521753 expect(sendMessageMock).toHaveBeenCalledWith({1754 type: "CREATE_VAULT",1755 payload: { password: "fresh-master-password" },1756 });1757 expect(sendMessageMock).toHaveBeenCalledWith({1758 type: "BATCH_ADD_ENTRIES",1759 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1760 });1761 expect(onImport).toHaveBeenCalled();1762 });17631764});