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 });844845 it("Settings keeps connection and recovery details collapsed until requested", async () => {846 sendMessageMock.mockImplementation(async (msg: any) => {847 if (msg?.type === "LIST_PAIRED_DEVICES") {848 return { ok: true, data: { devices: [] } };849 }850 return { ok: true, data: null };851 });852853 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);854 for (let i = 0; i < 30; i++) await Promise.resolve();855 Array.from(container.querySelectorAll("button"))856 .find((button) => /sync/i.test(button.textContent ?? ""))857 ?.click();858 for (let i = 0; i < 20; i++) await Promise.resolve();859860 expect(container.textContent).toContain("SYNC STATUS");861 expect(container.textContent).toContain("ADD A DEVICE");862 expect(container.textContent).not.toContain("Public relay");863 expect(container.textContent).not.toContain("Rotate sync identity");864865 const connection = Array.from(container.querySelectorAll("button"))866 .find((button) => button.textContent?.includes("CONNECTION"));867 connection?.click();868 await Promise.resolve();869 expect(container.textContent).toContain("Public relay");870871 const recovery = Array.from(container.querySelectorAll("button"))872 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));873 recovery?.click();874 await Promise.resolve();875 expect(container.textContent).toContain("Rotate sync identity");876 });877878 it("Settings requires current, new, and confirm passwords before enabling save", async () => {879 sendMessageMock.mockResolvedValue({ ok: true, data: null });880 const container = mount(881 <SettingsView882 entriesCount={1}883 breachMonitoring={true}884 lockOnClose={true}885 autoLockMinutes={5}886 trashRetentionDays={30}887 theme="dark"888 accentColor="peach"889 textSize="medium"890 reduceMotion={false}891 onBreachMonitoringChange={vi.fn()}892 onLockOnCloseChange={vi.fn()}893 onAutoLockMinutesChange={vi.fn()}894 onTrashRetentionChange={vi.fn()}895 onThemeChange={vi.fn()}896 onAccentChange={vi.fn()}897 onTextSizeChange={vi.fn()}898 onReduceMotionChange={vi.fn()}899 shortcuts={DEFAULT_BINDINGS}900 onShortcutsChange={vi.fn()}901 onBack={vi.fn()}902 onImport={vi.fn()}903 onExport={vi.fn()}904 onBreachCheckDone={vi.fn()}905 />906 );907 await flushView();908909 expect(container.querySelectorAll(".stk-card").length).toBe(7);910 911 912 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");913914 const changeButton = Array.from(container.querySelectorAll("button")).find(915 (candidate) => candidate.textContent?.trim() === "change"916 ) as HTMLButtonElement;917 changeButton.click();918 await flushView();919920 const saveButton = Array.from(container.querySelectorAll("button")).find(921 (candidate) => candidate.textContent?.trim() === "save new password"922 ) as HTMLButtonElement;923 expect(saveButton.disabled).toBe(true);924925 const inputs = container.querySelectorAll('input[type="password"]');926 inputText(inputs[0] as HTMLInputElement, "current-pass");927 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");928 await flushView();929 expect(saveButton.disabled).toBe(true);930931 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");932 await flushView();933 expect(saveButton.disabled).toBe(false);934 });935936 it("Settings feedback highlights the selected category and hides the form after submit", async () => {937 sendMessageMock.mockResolvedValue({ ok: true, data: null });938 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({939 ok: true,940 json: async () => ({ success: true }),941 }));942 vi.stubGlobal("fetch", fetchMock);943944 const container = mount(945 <SettingsView946 entriesCount={1}947 breachMonitoring={true}948 lockOnClose={true}949 autoLockMinutes={5}950 trashRetentionDays={30}951 theme="dark"952 accentColor="peach"953 textSize="medium"954 reduceMotion={false}955 initialSection="feedback"956 onBreachMonitoringChange={vi.fn()}957 onLockOnCloseChange={vi.fn()}958 onAutoLockMinutesChange={vi.fn()}959 onTrashRetentionChange={vi.fn()}960 onThemeChange={vi.fn()}961 onAccentChange={vi.fn()}962 onTextSizeChange={vi.fn()}963 onReduceMotionChange={vi.fn()}964 shortcuts={DEFAULT_BINDINGS}965 onShortcutsChange={vi.fn()}966 onBack={vi.fn()}967 onImport={vi.fn()}968 onExport={vi.fn()}969 onBreachCheckDone={vi.fn()}970 />971 );972 await flushView();973974 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;975 expect(feedbackCard.classList.contains("is-active")).toBe(true);976977 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];978 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;979 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;980 expect(bugButton.getAttribute("aria-pressed")).toBe("true");981 expect(featureButton.getAttribute("aria-pressed")).toBe("false");982983 featureButton.click();984 await flushView();985986 expect(bugButton.getAttribute("aria-pressed")).toBe("false");987 expect(featureButton.getAttribute("aria-pressed")).toBe("true");988 expect(featureButton.classList.contains("is-active")).toBe(true);989990 inputText(991 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,992 '<script>alert("xss")</script> Please make this easier to see & safer.'993 );994 const sendButton = Array.from(container.querySelectorAll("button")).find(995 (candidate) => candidate.textContent?.trim() === "Send"996 ) as HTMLButtonElement;997 sendButton.click();998999 await vi.waitFor(() => {1000 expect(container.textContent).toContain("Thank you");1001 });10021003 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1004 expect(requestBody.subject).toContain("Feature request");1005 expect(requestBody.message).toBe(1006 "<script>alert("xss")</script> Please make this easier to see & safer."1007 );1008 expect(requestBody.message).not.toContain("<script>");1009 expect(container.textContent).toContain("Someone will process it soon");1010 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1011 expect(container.querySelector(".peach-feedback-submit")).toBeNull();1012 });10131014 it("Settings keeps license activation on the full-screen celebration path", async () => {1015 const onLicenseChange = vi.fn(async () => undefined);1016 sendMessageMock.mockImplementation(async (msg: unknown) => {1017 const type = (msg as { type?: string }).type;1018 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1019 if (type === "ACTIVATE_LICENSE") {1020 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1021 }1022 if (type === "GET_ENTRIES") return { ok: true, data: [] };1023 return { ok: true, data: null };1024 });10251026 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1027 await flushView();10281029 inputText(1030 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1031 "PEACH-TEST-KEY",1032 );1033 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1034 apply.click();10351036 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1037 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1038 expect(container.textContent).not.toContain("thank you ✦");1039 });10401041 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1042 sendMessageMock.mockImplementation(async (msg: unknown) => {1043 const type = (msg as { type?: string }).type;1044 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1045 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1046 if (type === "GET_ENTRIES") return { ok: true, data: [] };1047 return { ok: true, data: null };1048 });10491050 const onLicenseChangeFromStar = vi.fn();1051 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1052 await flushView();1053 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1054 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1055 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10561057 const onLicenseChangeFromPeach = vi.fn();1058 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1059 await flushView();1060 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1061 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1062 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1063 });10641065 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1066 sendMessageMock.mockResolvedValue({ ok: true, data: null });1067 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1068 ok: true,1069 json: async () => ({ success: true }),1070 }));1071 vi.stubGlobal("fetch", fetchMock);1072 1073 1074 1075 vi.stubGlobal("browser", {1076 permissions: {1077 getAll: vi.fn(async () => ({ data_collection: [] })),1078 request: vi.fn(async () => false),1079 },1080 });10811082 const container = mount(1083 <SettingsView1084 entriesCount={1}1085 breachMonitoring={true}1086 lockOnClose={true}1087 autoLockMinutes={5}1088 trashRetentionDays={30}1089 theme="dark"1090 accentColor="peach"1091 textSize="medium"1092 reduceMotion={false}1093 initialSection="feedback"1094 onBreachMonitoringChange={vi.fn()}1095 onLockOnCloseChange={vi.fn()}1096 onAutoLockMinutesChange={vi.fn()}1097 onTrashRetentionChange={vi.fn()}1098 onThemeChange={vi.fn()}1099 onAccentChange={vi.fn()}1100 onTextSizeChange={vi.fn()}1101 onReduceMotionChange={vi.fn()}1102 shortcuts={DEFAULT_BINDINGS}1103 onShortcutsChange={vi.fn()}1104 onBack={vi.fn()}1105 onImport={vi.fn()}1106 onExport={vi.fn()}1107 onBreachCheckDone={vi.fn()}1108 />1109 );1110 await flushView();11111112 inputText(1113 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1114 "Please improve the feedback flow."1115 );1116 const sendButton = Array.from(container.querySelectorAll("button")).find(1117 (candidate) => candidate.textContent?.trim() === "Send"1118 ) as HTMLButtonElement;1119 sendButton.click();11201121 await vi.waitFor(() => {1122 expect(fetchMock).toHaveBeenCalledTimes(1);1123 });1124 await vi.waitFor(() => {1125 expect(container.textContent).toContain("Thank you");1126 });1127 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1128 });11291130 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1131 sendMessageMock.mockImplementation(async (msg: unknown) => {1132 const m = msg as { type: string };1133 if (m.type === "GET_RECOVERY_WORDS") {1134 return {1135 ok: true,1136 data: [1137 "abandon", "ability", "able", "about", "above", "absent",1138 "absorb", "abstract", "absurd", "abuse", "access", "accident",1139 ],1140 };1141 }1142 return { ok: true, data: null };1143 });1144 const onAutoLockMinutesChange = vi.fn();1145 const onThemeChange = vi.fn();1146 const container = mount(1147 <SettingsView1148 entriesCount={1}1149 breachMonitoring={true}1150 lockOnClose={true}1151 autoLockMinutes={5}1152 trashRetentionDays={30}1153 theme="dark"1154 accentColor="peach"1155 textSize="medium"1156 reduceMotion={false}1157 onBreachMonitoringChange={vi.fn()}1158 onLockOnCloseChange={vi.fn()}1159 onAutoLockMinutesChange={onAutoLockMinutesChange}1160 onTrashRetentionChange={vi.fn()}1161 onThemeChange={onThemeChange}1162 onAccentChange={vi.fn()}1163 onTextSizeChange={vi.fn()}1164 onReduceMotionChange={vi.fn()}1165 shortcuts={DEFAULT_BINDINGS}1166 onShortcutsChange={vi.fn()}1167 onBack={vi.fn()}1168 onImport={vi.fn()}1169 onExport={vi.fn()}1170 onBreachCheckDone={vi.fn()}1171 />1172 );1173 await flushView();1174 sendMessageMock.mockClear();11751176 1177 const selects = container.querySelectorAll("select");1178 changeSelect(selects[0] as HTMLSelectElement, "15");1179 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11801181 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1182 appearanceCard.click();1183 await flushView();1184 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1185 const themeButton = Array.from(container.querySelectorAll("button")).find(1186 (candidate) => candidate.textContent?.trim() === "Switch to light"1187 ) as HTMLButtonElement;1188 themeButton.click();1189 expect(onThemeChange).toHaveBeenCalledWith("light");11901191 1192 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1193 protectionCard.click();1194 await flushView();11951196 const changeButton = Array.from(container.querySelectorAll("button")).find(1197 (candidate) => candidate.textContent?.trim() === "change"1198 ) as HTMLButtonElement;1199 changeButton.click();1200 await flushView();12011202 const inputs = container.querySelectorAll('input[type="password"]');1203 inputText(inputs[0] as HTMLInputElement, "current-pass");1204 inputText(inputs[1] as HTMLInputElement, "new-password-123");1205 inputText(inputs[2] as HTMLInputElement, "new-password-123");1206 await flushView();12071208 const saveButton = Array.from(container.querySelectorAll("button")).find(1209 (candidate) => candidate.textContent?.trim() === "save new password"1210 ) as HTMLButtonElement;1211 saveButton.click();1212 await flushView();12131214 expect(sendMessageMock).toHaveBeenCalledWith({1215 type: "CHANGE_PASSWORD",1216 payload: {1217 currentPassword: "current-pass",1218 newPassword: "new-password-123",1219 },1220 });12211222 1223 expect(container.textContent).toContain("save your recovery words");1224 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1225 });12261227 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1228 sendMessageMock.mockResolvedValue({ ok: true });1229 const container = mount(1230 <EntryView1231 entry={sampleEntry()}1232 onDelete={vi.fn()}1233 onFieldSaved={vi.fn(async () => undefined)}1234 />1235 );12361237 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1238 passwordButton.click();1239 await flushView();12401241 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1242 type: "COPY",1243 payload: { text: "secret123", label: "password", alreadyWritten: false },1244 });1245 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1246 type: "FIRE_CANARY_PING",1247 payload: {1248 webhookUrl: "https://example.com/hook",1249 entryTitle: "GitHub",1250 trigger: "password_copied",1251 },1252 });12531254 sendMessageMock.mockClear();1255 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1256 usernameButton.click();1257 await flushView();12581259 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1260 type: "COPY",1261 payload: { text: "june", label: "username", alreadyWritten: false },1262 });1263 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1264 type: "FIRE_CANARY_PING",1265 payload: {1266 webhookUrl: "https://example.com/hook",1267 entryTitle: "GitHub",1268 trigger: "username_copied",1269 },1270 });1271 });12721273 it("Entry keyboard password copy reports its canary after COPY", async () => {1274 sendMessageMock.mockResolvedValue({ ok: true });1275 mount(1276 <EntryView1277 entry={sampleEntry()}1278 onDelete={vi.fn()}1279 onFieldSaved={vi.fn(async () => undefined)}1280 />1281 );12821283 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1284 await flushView();12851286 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1287 type: "COPY",1288 payload: { text: "secret123", label: "password", alreadyWritten: false },1289 });1290 await flushView();1291 expect(sendMessageMock.mock.calls).toContainEqual([{1292 type: "FIRE_CANARY_PING",1293 payload: {1294 webhookUrl: "https://example.com/hook",1295 entryTitle: "GitHub",1296 trigger: "password_copied",1297 },1298 }]);1299 });13001301 it("Vault password swipe copy reports its canary after COPY", async () => {1302 sendMessageMock.mockResolvedValue({ ok: true });1303 const container = mount(1304 <VaultView1305 entries={[sampleEntry()]}1306 sortOrder="url-first"1307 onSelectEntry={vi.fn()}1308 onSortOrderChange={vi.fn()}1309 onAdd={vi.fn()}1310 onClose={vi.fn()}1311 />1312 );1313 const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1314 row.dispatchEvent(new TouchEvent("touchstart", {1315 bubbles: true,1316 touches: [{ clientX: 0, clientY: 0 } as Touch],1317 }));1318 row.dispatchEvent(new TouchEvent("touchend", {1319 bubbles: true,1320 changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1321 }));1322 await flushView();13231324 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1325 type: "COPY",1326 payload: { text: "secret123", label: "password", alreadyWritten: false },1327 });1328 await flushView();1329 expect(sendMessageMock.mock.calls).toContainEqual([{1330 type: "FIRE_CANARY_PING",1331 payload: {1332 webhookUrl: "https://example.com/hook",1333 entryTitle: "GitHub",1334 trigger: "password_copied",1335 },1336 }]);1337 });13381339 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1340 const container = mount(1341 <EntryView1342 entry={sampleEntry({1343 type: "passkey",1344 username: undefined,1345 password: undefined,1346 totp: undefined,1347 canary: undefined,1348 passkey: {1349 rpId: "vercel.com",1350 rpName: "Vercel",1351 credentialId: "cred-1",1352 publicKeyCose: "cose",1353 userHandle: "handle",1354 userName: "june",1355 userDisplayName: "June",1356 algorithm: -7,1357 signCount: 0,1358 aaguid: "",1359 backupEligible: false,1360 backupState: false,1361 createdAt: 1,1362 },1363 })}1364 onDelete={vi.fn()}1365 onFieldSaved={vi.fn(async () => undefined)}1366 />1367 );13681369 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1370 (button) => button.textContent?.includes("Advanced")1371 ) as HTMLButtonElement | undefined;1372 expect(advanced).toBeDefined();1373 expect(advanced?.textContent).toContain("+ Advanced");13741375 advanced!.click();1376 await flushView();13771378 expect(container.textContent).toContain("passkey · vercel.com");1379 });13801381 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1382 sendMessageMock.mockResolvedValue({ ok: true });1383 const onFieldSaved = vi.fn(async () => undefined);1384 const container = mount(1385 <EntryView1386 entry={sampleEntry()}1387 onDelete={vi.fn()}1388 onFieldSaved={onFieldSaved}1389 />1390 );13911392 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1393 passwordValue.click();1394 await flushView();13951396 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1397 inputText(passwordInput, "");1398 await flushView();13991400 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1401 saveButton.click();1402 await flushView();14031404 expect(sendMessageMock).not.toHaveBeenCalledWith(1405 expect.objectContaining({ type: "UPDATE_ENTRY" })1406 );1407 expect(onFieldSaved).not.toHaveBeenCalled();14081409 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1410 totpMain.click();14111412 await vi.waitFor(() => {1413 expect(sendMessageMock).toHaveBeenLastCalledWith({1414 type: "COPY",1415 payload: { text: "123456", label: "totp", alreadyWritten: false },1416 });1417 }, { timeout: 500 });1418 });14191420 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1421 sendMessageMock.mockResolvedValue({ ok: true });1422 const container = mount(1423 <EntryView1424 entry={sampleEntry()}1425 onDelete={vi.fn()}1426 onFieldSaved={vi.fn(async () => undefined)}1427 />1428 );14291430 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1431 usernameValue.click();1432 await flushView();14331434 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1435 usernameInput.blur();1436 await flushView();14371438 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1439 expect(sendMessageMock).not.toHaveBeenCalledWith(1440 expect.objectContaining({ type: "UPDATE_ENTRY" })1441 );1442 });14431444 it("Entry keeps the full used-email list available while editing an email", async () => {1445 const container = mount(1446 <EntryView1447 entry={sampleEntry({ email: "[email protected]" })}1448 entries={[1449 sampleEntry({ email: "[email protected]" }),1450 sampleEntry({ id: "entry-2", email: "[email protected]" }),1451 sampleEntry({ id: "entry-3", username: "duckuccino" }),1452 ]}1453 onDelete={vi.fn()}1454 onFieldSaved={vi.fn(async () => undefined)}1455 />1456 );14571458 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1459 (card) => card.querySelector(".peach-label")?.textContent === "email"1460 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1461 emailValue.click();1462 await flushView();14631464 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1465 .map((element) => element.textContent);1466 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1467 });14681469 it("Entry lets TOTP secrets be edited inline", async () => {1470 sendMessageMock.mockResolvedValue({ ok: true });1471 const onFieldSaved = vi.fn(async () => undefined);1472 const container = mount(1473 <EntryView1474 entry={sampleEntry()}1475 onDelete={vi.fn()}1476 onFieldSaved={onFieldSaved}1477 />1478 );14791480 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1481 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1482 await flushView();14831484 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1485 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1486 await flushView();14871488 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1489 saveButton.click();1490 await flushView();14911492 expect(sendMessageMock).toHaveBeenCalledWith({1493 type: "UPDATE_ENTRY",1494 payload: {1495 id: "entry-1",1496 patch: { totp: "NB2W45DFOIZA====" },1497 },1498 });1499 expect(onFieldSaved).toHaveBeenCalled();1500 });15011502 it("Vault seeds and focuses search when a search seed is provided", async () => {1503 const container = mount(1504 <VaultView1505 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1506 sortOrder="url-first"1507 searchSeed="g"1508 searchSeedNonce={1}1509 onSelectEntry={vi.fn()}1510 onSortOrderChange={vi.fn()}1511 onAdd={vi.fn()}1512 onClose={vi.fn()}1513 />1514 );15151516 await flushView();15171518 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1519 expect(search.value).toBe("g");1520 expect(document.activeElement).toBe(search);1521 });15221523 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1524 parseImportSourceMock.mockResolvedValue(1525 sampleImportResult({1526 entries: [1527 {1528 type: "login",1529 title: "Imported site",1530 username: "imported-user",1531 password: "imported-pass",1532 url: "https://imported.test",1533 },1534 ],1535 })1536 );1537 sendMessageMock.mockImplementation(async (msg: unknown) => {1538 const type = (msg as { type?: string }).type;1539 if (type === "GET_ENTRIES") {1540 return {1541 ok: true,1542 data: [1543 sampleEntry({1544 id: "existing-1",1545 title: "Imported site",1546 username: "imported-user",1547 url: "https://imported.test",1548 }),1549 ],1550 };1551 }1552 if (type === "BATCH_ADD_ENTRIES") {1553 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1554 }1555 return { ok: true };1556 });1557 const onImport = vi.fn(async () => undefined);1558 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1559 await flushView();1560 sendMessageMock.mockClear();15611562 await selectImportFile(container);1563 findButton(container, "Import 1").click();1564 await flushView();15651566 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1567 expect(sendMessageMock).toHaveBeenCalledWith({1568 type: "BATCH_ADD_ENTRIES",1569 payload: {1570 entries: [1571 expect.objectContaining({1572 title: "Imported site",1573 username: "imported-user",1574 password: "imported-pass",1575 url: "https://imported.test",1576 }),1577 ],1578 },1579 });1580 expect(onImport).toHaveBeenCalled();1581 expect(container.textContent).toContain("1 entry appears to be duplicates");1582 });15831584 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1585 parseImportSourceMock.mockResolvedValue(sampleImportResult());1586 sendMessageMock.mockImplementation(async (msg: unknown) => {1587 const type = (msg as { type?: string }).type;1588 if (type === "GET_ENTRIES") {1589 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1590 }1591 if (type === "CLEAR_VAULT_ENTRIES") {1592 return { ok: true };1593 }1594 if (type === "BATCH_ADD_ENTRIES") {1595 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1596 }1597 return { ok: true };1598 });1599 const onImport = vi.fn(async () => undefined);1600 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1601 await flushView();1602 sendMessageMock.mockClear();16031604 await selectImportFile(container);1605 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1606 await flushView();16071608 findButton(container, "Replace vault with 1").click();1609 await flushView();16101611 expect(sendMessageMock).not.toHaveBeenCalledWith(1612 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1613 );1614 expect(container.textContent).toContain("Replace forever? This cannot be undone.");16151616 findButton(container, "Yes, replace forever").click();1617 await flushView();16181619 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1620 expect(sendMessageMock).toHaveBeenCalledWith({1621 type: "BATCH_ADD_ENTRIES",1622 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1623 });1624 expect(onImport).toHaveBeenCalled();1625 });16261627 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1628 parseImportSourceMock.mockResolvedValue(sampleImportResult());1629 sendMessageMock.mockImplementation(async (msg: unknown) => {1630 const type = (msg as { type?: string }).type;1631 if (type === "CREATE_VAULT") return { ok: true };1632 if (type === "BATCH_ADD_ENTRIES") {1633 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1634 }1635 return { ok: true };1636 });1637 const onImport = vi.fn(async () => undefined);1638 const container = mount(1639 <ImportView1640 mode="onboarding"1641 replacesExistingVault={true}1642 onBack={vi.fn()}1643 onImport={onImport}1644 />1645 );16461647 await selectImportFile(container);16481649 const createButton = findButton(container, "Create vault + import 1");1650 expect(createButton.disabled).toBe(true);1651 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1652 expect(container.textContent).toContain("Use at least 8 characters");16531654 const masterInput = container.querySelector(1655 'input[placeholder="new master password"]'1656 ) as HTMLInputElement;1657 const confirmInput = container.querySelector(1658 'input[placeholder="confirm master password"]'1659 ) as HTMLInputElement;1660 const revealButton = container.querySelector(1661 'button[aria-label="Show passwords"]'1662 ) as HTMLButtonElement;1663 expect(revealButton).toBeTruthy();1664 revealButton.click();1665 await flushView();1666 expect(masterInput.type).toBe("text");1667 expect(confirmInput.type).toBe("text");16681669 inputText(masterInput, "fresh-master-password");1670 inputText(confirmInput, "fresh-master-password");1671 await flushView();16721673 expect(createButton.disabled).toBe(false);1674 createButton.click();1675 await flushView();16761677 expect(sendMessageMock).toHaveBeenCalledWith({1678 type: "CREATE_VAULT",1679 payload: { password: "fresh-master-password" },1680 });1681 expect(sendMessageMock).toHaveBeenCalledWith({1682 type: "BATCH_ADD_ENTRIES",1683 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1684 });1685 expect(onImport).toHaveBeenCalled();1686 });16871688});