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 { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26 sendMessage: (msg: unknown) => sendMessageMock(msg),27 triggerRing: () => triggerRingMock(),28 triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36 const actual = await importOriginal<typeof import("../../shared/favicon")>();37 return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41 generateTOTP: () => ({ code: "123 456", remaining: 24 }),42 parseOtpauthURI: (value: string) =>43 value.startsWith("otpauth://")44 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45 : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49 GeneratorDrawer: () => null,50}));5152vi.mock("../components/RuneBackground", () => ({53 RuneBackground: () => null,54}));5556vi.mock("@zxcvbn-ts/core", () => ({57 zxcvbn: (value: string) => zxcvbnMock(value),58 zxcvbnOptions: { setOptions: vi.fn() },59}));60vi.mock("@zxcvbn-ts/language-common", () => ({61 adjacencyGraphs: {},62 dictionary: {},63}));64vi.mock("@zxcvbn-ts/language-en", () => ({65 translations: {},66 dictionary: {},67}));6869describe("popup contract tests", () => {70 const disposers: Array<() => void> = [];7172 beforeEach(() => {73 document.body.innerHTML = "";74 sendMessageMock.mockReset();75 triggerRingMock.mockReset();76 triggerPipPulseMock.mockReset();77 parseImportSourceMock.mockReset();78 zxcvbnMock.mockReset();79 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));80 parseImportSourceMock.mockResolvedValue({81 format: "unknown",82 label: "Unknown format",83 entries: [],84 totalItems: 0,85 skippedItems: 0,86 requiresPassword: false,87 });8889 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {90 return window.setTimeout(() => cb(0), 0) as unknown as number;91 });92 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));93 vi.stubGlobal("URL", {94 createObjectURL: vi.fn(() => "blob:peach-test"),95 revokeObjectURL: vi.fn(),96 });97 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9899 (globalThis as { chrome?: typeof chrome }).chrome = {100 runtime: {101 getManifest: () => ({ version: "1.4.47" }),102 onMessage: { addListener: vi.fn() },103 },104 } as unknown as typeof chrome;105 });106107 afterEach(() => {108 while (disposers.length > 0) {109 disposers.pop()?.();110 }111 vi.restoreAllMocks();112 vi.unstubAllGlobals();113 });114115 function mount(view: JSX.Element | (() => JSX.Element)) {116 const mounted = renderView(view);117 disposers.push(mounted.dispose);118 return mounted.container;119 }120121 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {122 return {123 id: "entry-1",124 type: "login",125 title: "GitHub",126 username: "june",127 password: "secret123",128 url: "https://github.com",129 totp: "JBSWY3DPEHPK3PXP",130 canary: {131 webhookUrl: "https://example.com/hook",132 },133 createdAt: 1,134 modifiedAt: 1,135 ...overrides,136 };137 }138139 function sampleImportResult(140 overrides: Partial<ImportParseResult> = {}141 ): ImportParseResult {142 return {143 format: "csv",144 label: "CSV import",145 entries: [146 {147 type: "login",148 title: "Imported site",149 username: "imported-user",150 password: "imported-pass",151 url: "https://imported.test",152 },153 ],154 totalItems: 1,155 skippedItems: 0,156 requiresPassword: false,157 ...overrides,158 };159 }160161 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {162 return {163 entriesCount: 1,164 breachMonitoring: true,165 lockOnClose: true,166 autoLockMinutes: 5,167 trashRetentionDays: 30,168 theme: "dark" as const,169 accentColor: "peach" as const,170 textSize: "medium" as const,171 reduceMotion: false,172 onBreachMonitoringChange: vi.fn(),173 onLockOnCloseChange: vi.fn(),174 onAutoLockMinutesChange: vi.fn(),175 onTrashRetentionChange: vi.fn(),176 onThemeChange: vi.fn(),177 onAccentChange: vi.fn(),178 onTextSizeChange: vi.fn(),179 onReduceMotionChange: vi.fn(),180 shortcuts: DEFAULT_BINDINGS,181 onShortcutsChange: vi.fn(),182 onBack: vi.fn(),183 onImport: vi.fn(),184 onExport: vi.fn(),185 onBreachCheckDone: vi.fn(),186 ...overrides,187 };188 }189190 async function selectImportFile(191 container: HTMLElement,192 contents = '{"export":"test"}'193 ) {194 195 196 197 const fileCard = findButton(container, "Choose file →");198 if (fileCard) {199 fileCard.click();200 await flushView();201 }202 const input = container.querySelector('input[type="file"]') as HTMLInputElement;203 const file = new File([contents], "import.json", { type: "application/json" });204 Object.defineProperty(input, "files", {205 configurable: true,206 value: [file],207 });208 input.dispatchEvent(new Event("change", { bubbles: true }));209 await flushView();210 await flushView();211 }212213 function findButton(container: HTMLElement, text: string): HTMLButtonElement {214 return Array.from(container.querySelectorAll("button")).find(215 (candidate) => candidate.textContent?.trim() === text216 ) as HTMLButtonElement;217 }218219 it("Lock sends UNLOCK with the entered passphrase", async () => {220 sendMessageMock.mockResolvedValue({ ok: true });221 const onUnlock = vi.fn(async () => undefined);222 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);223224 const password = container.querySelector("#unlock-password") as HTMLInputElement;225 inputText(password, "correct horse battery staple");226 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();227 await flushView();228229 expect(sendMessageMock).toHaveBeenCalledWith({230 type: "UNLOCK",231 payload: { password: "correct horse battery staple" },232 });233 expect(onUnlock).toHaveBeenCalled();234 });235236 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {237 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);238 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;239 const password = container.querySelector("#master-password") as HTMLInputElement;240 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;241242 inputText(password, "short");243 inputText(confirm, "short");244 await flushView();245 expect(createButton.disabled).toBe(true);246247 zxcvbnMock.mockImplementation(() => ({ score: 1 }));248 inputText(password, "weakweakweak");249 inputText(confirm, "weakweakweak");250 await flushView();251 expect(createButton.disabled).toBe(true);252 });253254 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {255 256 sendMessageMock.mockImplementation(257 async (msg: unknown) => {258 const m = msg as { type: string; payload?: { password?: string } };259 if (m.type === "CREATE_VAULT") return { ok: true };260 if (m.type === "GET_RECOVERY_WORDS") {261 262 return {263 ok: true,264 data: [265 "abandon", "ability", "able", "about", "above", "absent",266 "absorb", "abstract", "absurd", "abuse", "access", "accident",267 ],268 };269 }270 return { ok: true };271 }272 );273 zxcvbnMock.mockImplementation(() => ({ score: 3 }));274 const onUnlock = vi.fn(async () => undefined);275 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);276277 const password = container.querySelector("#master-password") as HTMLInputElement;278 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;279 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;280281 inputText(password, "correct horse battery staple");282 inputText(confirm, "correct horse battery staple");283 await flushView();284 expect(createButton.disabled).toBe(false);285286 pressEnter(confirm);287 await flushView();288289 expect(sendMessageMock).toHaveBeenCalledWith({290 type: "CREATE_VAULT",291 payload: { password: "correct horse battery staple" },292 });293 294 295 expect(onUnlock).not.toHaveBeenCalled();296 });297298 it("Recovery words flow submits the new master password and completes", async () => {299 sendMessageMock.mockImplementation(async (msg: unknown) => {300 const type = (msg as { type?: string }).type;301 if (type === "VALIDATE_RECOVERY_WORDS") {302 return { ok: true, data: { valid: true, invalid: [] } };303 }304 if (type === "RECOVER_WITH_WORDS") return { ok: true };305 return { ok: true };306 });307 const canvasContext = {308 clearRect: vi.fn(),309 fillText: vi.fn(),310 beginPath: vi.fn(),311 closePath: vi.fn(),312 arc: vi.fn(),313 fill: vi.fn(),314 stroke: vi.fn(),315 save: vi.fn(),316 restore: vi.fn(),317 translate: vi.fn(),318 rotate: vi.fn(),319 scale: vi.fn(),320 drawImage: vi.fn(),321 measureText: vi.fn(() => ({ width: 0 })),322 setTransform: vi.fn(),323 fillRect: vi.fn(),324 strokeRect: vi.fn(),325 } as unknown as CanvasRenderingContext2D;326 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);327328 const onRecoveryComplete = vi.fn(async () => undefined);329 const container = mount(330 <RecoveryWordEntryView331 onCancel={vi.fn()}332 onRecoveryComplete={onRecoveryComplete}333 />334 );335336 const recoveryWords = WORD_LIST.slice(0, 12);337 for (let i = 0; i < recoveryWords.length; i++) {338 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;339 inputText(input, recoveryWords[i]!);340 }341 await flushView();342343 findButton(container, "verify words").click();344 await flushView();345346 expect(container.textContent).toContain("new master password");347348 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;349 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;350 inputText(newPassword, "fresh-recovery-password");351 inputText(confirmPassword, "fresh-recovery-password");352 await flushView();353354 findButton(container, "recover vault").click();355 await flushView();356357 expect(sendMessageMock).toHaveBeenCalledWith({358 type: "RECOVER_WITH_WORDS",359 payload: {360 words: recoveryWords,361 newPassword: "fresh-recovery-password",362 },363 });364 expect(onRecoveryComplete).toHaveBeenCalled();365 });366367 it("Export menu surfaces both backup formats", async () => {368 const container = mount(<ExportView onBack={vi.fn()} />);369 await flushView();370371 expect(container.textContent).toContain("PeachScript");372 expect(container.textContent).toContain("CSV");373 });374375 it("Lock can open directly on backup restore from onboarding", async () => {376 const container = mount(() => (377 <LockView378 hasVault={false}379 ready={true}380 onUnlock={vi.fn()}381 initialRestore={true}382 />383 ));384 await flushView();385386 expect(container.textContent).toContain("PeachScript");387 expect(container.textContent).toContain("Codex");388 expect(container.textContent).toContain("CSV");389 expect(container.querySelector("#master-password")).toBeNull();390 });391392 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {393 const device = {394 deviceId: "phone-1",395 deviceName: "June's Phone",396 deviceType: "android" as const,397 longTermPublicKeyHex: "a".repeat(64),398 signingPublicKeyHex: "b".repeat(64),399 pairedAt: 1,400 trustSource: "direct" as const,401 };402 sendMessageMock.mockImplementation(async (msg: unknown) => {403 const m = msg as { type: string };404 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };405 return { ok: true, data: null };406 });407408 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);409 for (let i = 0; i < 30; i++) await Promise.resolve();410411 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>412 /sync/i.test(b.textContent ?? ""),413 );414 openSync?.click();415 for (let i = 0; i < 30; i++) await Promise.resolve();416417 const remove = Array.from(container.querySelectorAll("button")).find(418 (b) => (b.textContent ?? "").trim() === "remove",419 );420 expect(remove).toBeTruthy();421422 423 424 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();425426 remove!.click();427 for (let i = 0; i < 10; i++) await Promise.resolve();428429 430 431 const note = container.querySelector(".peach-unpair-confirm-note");432 expect(note).toBeTruthy();433 const text = note!.textContent ?? "";434 expect(text).toContain("master password");435 expect(text).toContain("recovery words");436 expect(text).toContain("future changes");437 });438439 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {440 const device = {441 deviceId: "phone-1",442 deviceName: "June's Phone",443 deviceType: "android" as const,444 longTermPublicKeyHex: "a".repeat(64),445 signingPublicKeyHex: "b".repeat(64),446 pairedAt: 1,447 trustSource: "direct" as const,448 };449 let paired: typeof device[] = [device];450 sendMessageMock.mockImplementation(async (msg: any) => {451 if (msg?.type === "LIST_PAIRED_DEVICES") {452 return { ok: true, data: { devices: [...paired] } };453 }454 if (msg?.type === "UNPAIR_DEVICE") {455 456 457 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);458 return { ok: true };459 }460 if (msg?.type === "RENAME_PAIRED_DEVICE") {461 paired = paired.map((d) =>462 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,463 );464 return { ok: true };465 }466 return { ok: true, data: null };467 });468469 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);470 for (let i = 0; i < 30; i++) await Promise.resolve();471 Array.from(container.querySelectorAll("button"))472 .find((b) => /sync/i.test(b.textContent ?? ""))473 ?.click();474 for (let i = 0; i < 30; i++) await Promise.resolve();475476 477 expect(container.textContent).toContain("PAIRED DEVICES");478 expect(container.textContent).toContain("June's Phone");479 expect(container.textContent).toContain("Android");480 Array.from(container.querySelectorAll("button"))481 .find((b) => (b.textContent ?? "").trim() === "rename")!482 .click();483 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;484 renameInput.value = "Travel phone";485 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));486 Array.from(container.querySelectorAll("button"))487 .find((b) => (b.textContent ?? "").trim() === "save")!488 .click();489 for (let i = 0; i < 30; i++) await Promise.resolve();490 expect(sendMessageMock).toHaveBeenCalledWith({491 type: "RENAME_PAIRED_DEVICE",492 payload: { deviceId: "phone-1", name: "Travel phone" },493 });494 expect(container.textContent).toContain("Travel phone");495 const syncNowButton = container.querySelector<HTMLButtonElement>(496 'button[aria-label="Sync this vault now"]',497 )!;498 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));499 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;500 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;501 expect(502 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,503 ).toBeTruthy();504 expect(505 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,506 ).toBeTruthy();507508 Array.from(container.querySelectorAll("button"))509 .find((b) => (b.textContent ?? "").trim() === "remove")!510 .click();511 for (let i = 0; i < 10; i++) await Promise.resolve();512 const confirm = Array.from(513 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),514 ).find((b) => (b.textContent ?? "").trim() === "remove");515 confirm!.click();516 for (let i = 0; i < 30; i++) await Promise.resolve();517518 expect(sendMessageMock).toHaveBeenCalledWith({519 type: "UNPAIR_DEVICE",520 payload: { deviceId: "phone-1" },521 });522 523 524 525 expect(container.textContent).not.toContain("June's Phone");526 expect(container.textContent).not.toContain("PAIRED DEVICES");527 });528529 it("Settings keeps connection and recovery details collapsed until requested", async () => {530 sendMessageMock.mockImplementation(async (msg: any) => {531 if (msg?.type === "LIST_PAIRED_DEVICES") {532 return { ok: true, data: { devices: [] } };533 }534 return { ok: true, data: null };535 });536537 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);538 for (let i = 0; i < 30; i++) await Promise.resolve();539 Array.from(container.querySelectorAll("button"))540 .find((button) => /sync/i.test(button.textContent ?? ""))541 ?.click();542 for (let i = 0; i < 20; i++) await Promise.resolve();543544 expect(container.textContent).toContain("SYNC STATUS");545 expect(container.textContent).toContain("ADD A DEVICE");546 expect(container.textContent).not.toContain("Public relay");547 expect(container.textContent).not.toContain("Rotate sync identity");548549 const connection = Array.from(container.querySelectorAll("button"))550 .find((button) => button.textContent?.includes("CONNECTION"));551 connection?.click();552 await Promise.resolve();553 expect(container.textContent).toContain("Public relay");554555 const recovery = Array.from(container.querySelectorAll("button"))556 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));557 recovery?.click();558 await Promise.resolve();559 expect(container.textContent).toContain("Rotate sync identity");560 });561562 it("Settings requires current, new, and confirm passwords before enabling save", async () => {563 sendMessageMock.mockResolvedValue({ ok: true, data: null });564 const container = mount(565 <SettingsView566 entriesCount={1}567 breachMonitoring={true}568 lockOnClose={true}569 autoLockMinutes={5}570 trashRetentionDays={30}571 theme="dark"572 accentColor="peach"573 textSize="medium"574 reduceMotion={false}575 onBreachMonitoringChange={vi.fn()}576 onLockOnCloseChange={vi.fn()}577 onAutoLockMinutesChange={vi.fn()}578 onTrashRetentionChange={vi.fn()}579 onThemeChange={vi.fn()}580 onAccentChange={vi.fn()}581 onTextSizeChange={vi.fn()}582 onReduceMotionChange={vi.fn()}583 shortcuts={DEFAULT_BINDINGS}584 onShortcutsChange={vi.fn()}585 onBack={vi.fn()}586 onImport={vi.fn()}587 onExport={vi.fn()}588 onBreachCheckDone={vi.fn()}589 />590 );591 await flushView();592593 expect(container.querySelectorAll(".stk-card").length).toBe(7);594 595 596 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");597598 const changeButton = Array.from(container.querySelectorAll("button")).find(599 (candidate) => candidate.textContent?.trim() === "change"600 ) as HTMLButtonElement;601 changeButton.click();602 await flushView();603604 const saveButton = Array.from(container.querySelectorAll("button")).find(605 (candidate) => candidate.textContent?.trim() === "save new password"606 ) as HTMLButtonElement;607 expect(saveButton.disabled).toBe(true);608609 const inputs = container.querySelectorAll('input[type="password"]');610 inputText(inputs[0] as HTMLInputElement, "current-pass");611 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");612 await flushView();613 expect(saveButton.disabled).toBe(true);614615 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");616 await flushView();617 expect(saveButton.disabled).toBe(false);618 });619620 it("Settings feedback highlights the selected category and hides the form after submit", async () => {621 sendMessageMock.mockResolvedValue({ ok: true, data: null });622 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({623 ok: true,624 json: async () => ({ success: true }),625 }));626 vi.stubGlobal("fetch", fetchMock);627628 const container = mount(629 <SettingsView630 entriesCount={1}631 breachMonitoring={true}632 lockOnClose={true}633 autoLockMinutes={5}634 trashRetentionDays={30}635 theme="dark"636 accentColor="peach"637 textSize="medium"638 reduceMotion={false}639 initialSection="feedback"640 onBreachMonitoringChange={vi.fn()}641 onLockOnCloseChange={vi.fn()}642 onAutoLockMinutesChange={vi.fn()}643 onTrashRetentionChange={vi.fn()}644 onThemeChange={vi.fn()}645 onAccentChange={vi.fn()}646 onTextSizeChange={vi.fn()}647 onReduceMotionChange={vi.fn()}648 shortcuts={DEFAULT_BINDINGS}649 onShortcutsChange={vi.fn()}650 onBack={vi.fn()}651 onImport={vi.fn()}652 onExport={vi.fn()}653 onBreachCheckDone={vi.fn()}654 />655 );656 await flushView();657658 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;659 expect(feedbackCard.classList.contains("is-active")).toBe(true);660661 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];662 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;663 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;664 expect(bugButton.getAttribute("aria-pressed")).toBe("true");665 expect(featureButton.getAttribute("aria-pressed")).toBe("false");666667 featureButton.click();668 await flushView();669670 expect(bugButton.getAttribute("aria-pressed")).toBe("false");671 expect(featureButton.getAttribute("aria-pressed")).toBe("true");672 expect(featureButton.classList.contains("is-active")).toBe(true);673674 inputText(675 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,676 '<script>alert("xss")</script> Please make this easier to see & safer.'677 );678 const sendButton = Array.from(container.querySelectorAll("button")).find(679 (candidate) => candidate.textContent?.trim() === "Send"680 ) as HTMLButtonElement;681 sendButton.click();682683 await vi.waitFor(() => {684 expect(container.textContent).toContain("Thank you");685 });686687 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));688 expect(requestBody.subject).toContain("Feature request");689 expect(requestBody.message).toBe(690 "<script>alert("xss")</script> Please make this easier to see & safer."691 );692 expect(requestBody.message).not.toContain("<script>");693 expect(container.textContent).toContain("Someone will process it soon");694 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();695 expect(container.querySelector(".peach-feedback-submit")).toBeNull();696 });697698 it("Settings keeps license activation on the full-screen celebration path", async () => {699 const onLicenseChange = vi.fn(async () => undefined);700 sendMessageMock.mockImplementation(async (msg: unknown) => {701 const type = (msg as { type?: string }).type;702 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };703 if (type === "ACTIVATE_LICENSE") {704 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };705 }706 if (type === "GET_ENTRIES") return { ok: true, data: [] };707 return { ok: true, data: null };708 });709710 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);711 await flushView();712713 inputText(714 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,715 "PEACH-TEST-KEY",716 );717 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;718 apply.click();719720 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));721 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);722 expect(container.textContent).not.toContain("thank you ✦");723 });724725 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {726 sendMessageMock.mockImplementation(async (msg: unknown) => {727 const type = (msg as { type?: string }).type;728 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };729 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };730 if (type === "GET_ENTRIES") return { ok: true, data: [] };731 return { ok: true, data: null };732 });733734 const onLicenseChangeFromStar = vi.fn();735 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);736 await flushView();737 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();738 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);739 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();740741 const onLicenseChangeFromPeach = vi.fn();742 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);743 await flushView();744 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();745 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);746 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();747 });748749 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {750 sendMessageMock.mockResolvedValue({ ok: true, data: null });751 const fetchMock = vi.fn();752 const requestMock = vi.fn(async () => false);753 vi.stubGlobal("fetch", fetchMock);754 vi.stubGlobal("browser", {755 permissions: {756 getAll: vi.fn(async () => ({ data_collection: [] })),757 request: requestMock,758 },759 });760761 const container = mount(762 <SettingsView763 entriesCount={1}764 breachMonitoring={true}765 lockOnClose={true}766 autoLockMinutes={5}767 trashRetentionDays={30}768 theme="dark"769 accentColor="peach"770 textSize="medium"771 reduceMotion={false}772 initialSection="feedback"773 onBreachMonitoringChange={vi.fn()}774 onLockOnCloseChange={vi.fn()}775 onAutoLockMinutesChange={vi.fn()}776 onTrashRetentionChange={vi.fn()}777 onThemeChange={vi.fn()}778 onAccentChange={vi.fn()}779 onTextSizeChange={vi.fn()}780 onReduceMotionChange={vi.fn()}781 shortcuts={DEFAULT_BINDINGS}782 onShortcutsChange={vi.fn()}783 onBack={vi.fn()}784 onImport={vi.fn()}785 onExport={vi.fn()}786 onBreachCheckDone={vi.fn()}787 />788 );789 await flushView();790791 inputText(792 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,793 "Please improve the feedback flow."794 );795 const sendButton = Array.from(container.querySelectorAll("button")).find(796 (candidate) => candidate.textContent?.trim() === "Send"797 ) as HTMLButtonElement;798 sendButton.click();799800 await vi.waitFor(() => {801 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });802 });803 expect(fetchMock).not.toHaveBeenCalled();804 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();805 });806807 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {808 sendMessageMock.mockImplementation(async (msg: unknown) => {809 const m = msg as { type: string };810 if (m.type === "GET_RECOVERY_WORDS") {811 return {812 ok: true,813 data: [814 "abandon", "ability", "able", "about", "above", "absent",815 "absorb", "abstract", "absurd", "abuse", "access", "accident",816 ],817 };818 }819 return { ok: true, data: null };820 });821 const onAutoLockMinutesChange = vi.fn();822 const onThemeChange = vi.fn();823 const container = mount(824 <SettingsView825 entriesCount={1}826 breachMonitoring={true}827 lockOnClose={true}828 autoLockMinutes={5}829 trashRetentionDays={30}830 theme="dark"831 accentColor="peach"832 textSize="medium"833 reduceMotion={false}834 onBreachMonitoringChange={vi.fn()}835 onLockOnCloseChange={vi.fn()}836 onAutoLockMinutesChange={onAutoLockMinutesChange}837 onTrashRetentionChange={vi.fn()}838 onThemeChange={onThemeChange}839 onAccentChange={vi.fn()}840 onTextSizeChange={vi.fn()}841 onReduceMotionChange={vi.fn()}842 shortcuts={DEFAULT_BINDINGS}843 onShortcutsChange={vi.fn()}844 onBack={vi.fn()}845 onImport={vi.fn()}846 onExport={vi.fn()}847 onBreachCheckDone={vi.fn()}848 />849 );850 await flushView();851 sendMessageMock.mockClear();852853 854 const selects = container.querySelectorAll("select");855 changeSelect(selects[0] as HTMLSelectElement, "15");856 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);857858 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;859 appearanceCard.click();860 await flushView();861 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");862 const themeButton = Array.from(container.querySelectorAll("button")).find(863 (candidate) => candidate.textContent?.trim() === "Switch to light"864 ) as HTMLButtonElement;865 themeButton.click();866 expect(onThemeChange).toHaveBeenCalledWith("light");867868 869 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;870 protectionCard.click();871 await flushView();872873 const changeButton = Array.from(container.querySelectorAll("button")).find(874 (candidate) => candidate.textContent?.trim() === "change"875 ) as HTMLButtonElement;876 changeButton.click();877 await flushView();878879 const inputs = container.querySelectorAll('input[type="password"]');880 inputText(inputs[0] as HTMLInputElement, "current-pass");881 inputText(inputs[1] as HTMLInputElement, "new-password-123");882 inputText(inputs[2] as HTMLInputElement, "new-password-123");883 await flushView();884885 const saveButton = Array.from(container.querySelectorAll("button")).find(886 (candidate) => candidate.textContent?.trim() === "save new password"887 ) as HTMLButtonElement;888 saveButton.click();889 await flushView();890891 expect(sendMessageMock).toHaveBeenCalledWith({892 type: "CHANGE_PASSWORD",893 payload: {894 currentPassword: "current-pass",895 newPassword: "new-password-123",896 },897 });898899 900 expect(container.textContent).toContain("save your recovery words");901 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });902 });903904 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {905 sendMessageMock.mockResolvedValue({ ok: true });906 const container = mount(907 <EntryView908 entry={sampleEntry()}909 onDelete={vi.fn()}910 onFieldSaved={vi.fn(async () => undefined)}911 />912 );913914 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;915 passwordButton.click();916 await flushView();917918 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {919 type: "COPY",920 payload: { text: "secret123", label: "password", alreadyWritten: false },921 });922 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {923 type: "FIRE_CANARY_PING",924 payload: {925 webhookUrl: "https://example.com/hook",926 entryTitle: "GitHub",927 trigger: "password_copied",928 },929 });930931 sendMessageMock.mockClear();932 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;933 usernameButton.click();934 await flushView();935936 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {937 type: "COPY",938 payload: { text: "june", label: "username", alreadyWritten: false },939 });940 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {941 type: "FIRE_CANARY_PING",942 payload: {943 webhookUrl: "https://example.com/hook",944 entryTitle: "GitHub",945 trigger: "username_copied",946 },947 });948 });949950 it("Entry shows Advanced for passkey-bearing credential entries", async () => {951 const container = mount(952 <EntryView953 entry={sampleEntry({954 type: "passkey",955 username: undefined,956 password: undefined,957 totp: undefined,958 canary: undefined,959 passkey: {960 rpId: "vercel.com",961 rpName: "Vercel",962 credentialId: "cred-1",963 publicKeyCose: "cose",964 userHandle: "handle",965 userName: "june",966 userDisplayName: "June",967 algorithm: -7,968 signCount: 0,969 aaguid: "",970 backupEligible: false,971 backupState: false,972 createdAt: 1,973 },974 })}975 onDelete={vi.fn()}976 onFieldSaved={vi.fn(async () => undefined)}977 />978 );979980 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(981 (button) => button.textContent?.includes("Advanced")982 ) as HTMLButtonElement | undefined;983 expect(advanced).toBeDefined();984 expect(advanced?.textContent).toContain("+ Advanced");985986 advanced!.click();987 await flushView();988989 expect(container.textContent).toContain("passkey · vercel.com");990 });991992 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {993 sendMessageMock.mockResolvedValue({ ok: true });994 const onFieldSaved = vi.fn(async () => undefined);995 const container = mount(996 <EntryView997 entry={sampleEntry()}998 onDelete={vi.fn()}999 onFieldSaved={onFieldSaved}1000 />1001 );10021003 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1004 passwordValue.click();1005 await flushView();10061007 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1008 inputText(passwordInput, "");1009 await flushView();10101011 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1012 saveButton.click();1013 await flushView();10141015 expect(sendMessageMock).not.toHaveBeenCalledWith(1016 expect.objectContaining({ type: "UPDATE_ENTRY" })1017 );1018 expect(onFieldSaved).not.toHaveBeenCalled();10191020 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1021 totpMain.click();10221023 await vi.waitFor(() => {1024 expect(sendMessageMock).toHaveBeenLastCalledWith({1025 type: "COPY",1026 payload: { text: "123456", label: "totp", alreadyWritten: false },1027 });1028 }, { timeout: 500 });1029 });10301031 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1032 sendMessageMock.mockResolvedValue({ ok: true });1033 const container = mount(1034 <EntryView1035 entry={sampleEntry()}1036 onDelete={vi.fn()}1037 onFieldSaved={vi.fn(async () => undefined)}1038 />1039 );10401041 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1042 usernameValue.click();1043 await flushView();10441045 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1046 usernameInput.blur();1047 await flushView();10481049 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1050 expect(sendMessageMock).not.toHaveBeenCalledWith(1051 expect.objectContaining({ type: "UPDATE_ENTRY" })1052 );1053 });10541055 it("Entry lets TOTP secrets be edited inline", async () => {1056 sendMessageMock.mockResolvedValue({ ok: true });1057 const onFieldSaved = vi.fn(async () => undefined);1058 const container = mount(1059 <EntryView1060 entry={sampleEntry()}1061 onDelete={vi.fn()}1062 onFieldSaved={onFieldSaved}1063 />1064 );10651066 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1067 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1068 await flushView();10691070 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1071 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1072 await flushView();10731074 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1075 saveButton.click();1076 await flushView();10771078 expect(sendMessageMock).toHaveBeenCalledWith({1079 type: "UPDATE_ENTRY",1080 payload: {1081 id: "entry-1",1082 patch: { totp: "NB2W45DFOIZA====" },1083 },1084 });1085 expect(onFieldSaved).toHaveBeenCalled();1086 });10871088 it("Vault seeds and focuses search when a search seed is provided", async () => {1089 const container = mount(1090 <VaultView1091 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1092 sortOrder="url-first"1093 searchSeed="g"1094 searchSeedNonce={1}1095 onSelectEntry={vi.fn()}1096 onSortOrderChange={vi.fn()}1097 onAdd={vi.fn()}1098 onClose={vi.fn()}1099 />1100 );11011102 await flushView();11031104 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1105 expect(search.value).toBe("g");1106 expect(document.activeElement).toBe(search);1107 });11081109 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1110 parseImportSourceMock.mockResolvedValue(1111 sampleImportResult({1112 entries: [1113 {1114 type: "login",1115 title: "Imported site",1116 username: "imported-user",1117 password: "imported-pass",1118 url: "https://imported.test",1119 },1120 ],1121 })1122 );1123 sendMessageMock.mockImplementation(async (msg: unknown) => {1124 const type = (msg as { type?: string }).type;1125 if (type === "GET_ENTRIES") {1126 return {1127 ok: true,1128 data: [1129 sampleEntry({1130 id: "existing-1",1131 title: "Imported site",1132 username: "imported-user",1133 url: "https://imported.test",1134 }),1135 ],1136 };1137 }1138 if (type === "BATCH_ADD_ENTRIES") {1139 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1140 }1141 return { ok: true };1142 });1143 const onImport = vi.fn(async () => undefined);1144 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1145 await flushView();1146 sendMessageMock.mockClear();11471148 await selectImportFile(container);1149 findButton(container, "Import 1").click();1150 await flushView();11511152 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1153 expect(sendMessageMock).toHaveBeenCalledWith({1154 type: "BATCH_ADD_ENTRIES",1155 payload: {1156 entries: [1157 expect.objectContaining({1158 title: "Imported site",1159 username: "imported-user",1160 password: "imported-pass",1161 url: "https://imported.test",1162 }),1163 ],1164 },1165 });1166 expect(onImport).toHaveBeenCalled();1167 expect(container.textContent).toContain("1 entry appears to be duplicates");1168 });11691170 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1171 parseImportSourceMock.mockResolvedValue(sampleImportResult());1172 sendMessageMock.mockImplementation(async (msg: unknown) => {1173 const type = (msg as { type?: string }).type;1174 if (type === "GET_ENTRIES") {1175 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1176 }1177 if (type === "CLEAR_VAULT_ENTRIES") {1178 return { ok: true };1179 }1180 if (type === "BATCH_ADD_ENTRIES") {1181 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1182 }1183 return { ok: true };1184 });1185 const onImport = vi.fn(async () => undefined);1186 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1187 await flushView();1188 sendMessageMock.mockClear();11891190 await selectImportFile(container);1191 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1192 await flushView();11931194 findButton(container, "Replace vault with 1").click();1195 await flushView();11961197 expect(sendMessageMock).not.toHaveBeenCalledWith(1198 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1199 );1200 expect(container.textContent).toContain("Replace forever? This cannot be undone.");12011202 findButton(container, "Yes, replace forever").click();1203 await flushView();12041205 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1206 expect(sendMessageMock).toHaveBeenCalledWith({1207 type: "BATCH_ADD_ENTRIES",1208 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1209 });1210 expect(onImport).toHaveBeenCalled();1211 });12121213 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1214 parseImportSourceMock.mockResolvedValue(sampleImportResult());1215 sendMessageMock.mockImplementation(async (msg: unknown) => {1216 const type = (msg as { type?: string }).type;1217 if (type === "CREATE_VAULT") return { ok: true };1218 if (type === "BATCH_ADD_ENTRIES") {1219 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1220 }1221 return { ok: true };1222 });1223 const onImport = vi.fn(async () => undefined);1224 const container = mount(1225 <ImportView1226 mode="onboarding"1227 replacesExistingVault={true}1228 onBack={vi.fn()}1229 onImport={onImport}1230 />1231 );12321233 await selectImportFile(container);12341235 const createButton = findButton(container, "Create vault + import 1");1236 expect(createButton.disabled).toBe(true);1237 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1238 expect(container.textContent).toContain("Use at least 8 characters");12391240 const masterInput = container.querySelector(1241 'input[placeholder="new master password"]'1242 ) as HTMLInputElement;1243 const confirmInput = container.querySelector(1244 'input[placeholder="confirm master password"]'1245 ) as HTMLInputElement;1246 const revealButton = container.querySelector(1247 'button[aria-label="Show passwords"]'1248 ) as HTMLButtonElement;1249 expect(revealButton).toBeTruthy();1250 revealButton.click();1251 await flushView();1252 expect(masterInput.type).toBe("text");1253 expect(confirmInput.type).toBe("text");12541255 inputText(masterInput, "fresh-master-password");1256 inputText(confirmInput, "fresh-master-password");1257 await flushView();12581259 expect(createButton.disabled).toBe(false);1260 createButton.click();1261 await flushView();12621263 expect(sendMessageMock).toHaveBeenCalledWith({1264 type: "CREATE_VAULT",1265 payload: { password: "fresh-master-password" },1266 });1267 expect(sendMessageMock).toHaveBeenCalledWith({1268 type: "BATCH_ADD_ENTRIES",1269 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1270 });1271 expect(onImport).toHaveBeenCalled();1272 });12731274 it("Import encrypted backups reparse with the backup password before import", async () => {1275 parseImportSourceMock1276 .mockResolvedValueOnce(1277 sampleImportResult({1278 format: "peach-json",1279 label: "Peach encrypted backup",1280 entries: [],1281 totalItems: 0,1282 requiresPassword: true,1283 })1284 )1285 .mockResolvedValueOnce(1286 sampleImportResult({1287 format: "peach-json",1288 label: "Peach encrypted backup",1289 })1290 );1291 sendMessageMock.mockImplementation(async (msg: unknown) => {1292 const type = (msg as { type?: string }).type;1293 if (type === "GET_ENTRIES") return { ok: true, data: [] };1294 if (type === "BATCH_ADD_ENTRIES") {1295 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1296 }1297 return { ok: true };1298 });1299 const onImport = vi.fn(async () => undefined);1300 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1301 await flushView();1302 sendMessageMock.mockClear();13031304 await selectImportFile(container, '{"type":"peach-export"}');1305 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1306 inputText(password, "backup-passphrase");1307 pressEnter(password);1308 await flushView();1309 await flushView();13101311 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1312 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");13131314 findButton(container, "Import 1").click();1315 await flushView();13161317 expect(sendMessageMock).toHaveBeenCalledWith({1318 type: "BATCH_ADD_ENTRIES",1319 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1320 });1321 expect(onImport).toHaveBeenCalled();1322 });1323});