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("@zxcvbn-ts/core", () => ({53 zxcvbn: (value: string) => zxcvbnMock(value),54 zxcvbnOptions: { setOptions: vi.fn() },55}));56vi.mock("@zxcvbn-ts/language-common", () => ({57 adjacencyGraphs: {},58 dictionary: {},59}));60vi.mock("@zxcvbn-ts/language-en", () => ({61 translations: {},62 dictionary: {},63}));6465describe("popup contract tests", () => {66 const disposers: Array<() => void> = [];6768 beforeEach(() => {69 document.body.innerHTML = "";70 sendMessageMock.mockReset();71 triggerRingMock.mockReset();72 triggerPipPulseMock.mockReset();73 parseImportSourceMock.mockReset();74 zxcvbnMock.mockReset();75 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));76 parseImportSourceMock.mockResolvedValue({77 format: "unknown",78 label: "Unknown format",79 entries: [],80 totalItems: 0,81 skippedItems: 0,82 requiresPassword: false,83 });8485 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {86 return window.setTimeout(() => cb(0), 0) as unknown as number;87 });88 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));89 vi.stubGlobal("URL", {90 createObjectURL: vi.fn(() => "blob:peach-test"),91 revokeObjectURL: vi.fn(),92 });93 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9495 (globalThis as { chrome?: typeof chrome }).chrome = {96 runtime: {97 getManifest: () => ({ version: "1.4.47" }),98 onMessage: { addListener: vi.fn() },99 },100 } as unknown as typeof chrome;101 });102103 afterEach(() => {104 while (disposers.length > 0) {105 disposers.pop()?.();106 }107 vi.restoreAllMocks();108 vi.unstubAllGlobals();109 });110111 function mount(view: JSX.Element | (() => JSX.Element)) {112 const mounted = renderView(view);113 disposers.push(mounted.dispose);114 return mounted.container;115 }116117 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118 return {119 id: "entry-1",120 type: "login",121 title: "GitHub",122 username: "june",123 password: "secret123",124 url: "https://github.com",125 totp: "JBSWY3DPEHPK3PXP",126 canary: {127 webhookUrl: "https://example.com/hook",128 },129 createdAt: 1,130 modifiedAt: 1,131 ...overrides,132 };133 }134135 function sampleImportResult(136 overrides: Partial<ImportParseResult> = {}137 ): ImportParseResult {138 return {139 format: "csv",140 label: "CSV import",141 entries: [142 {143 type: "login",144 title: "Imported site",145 username: "imported-user",146 password: "imported-pass",147 url: "https://imported.test",148 },149 ],150 totalItems: 1,151 skippedItems: 0,152 requiresPassword: false,153 ...overrides,154 };155 }156157 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {158 return {159 entriesCount: 1,160 breachMonitoring: true,161 lockOnClose: true,162 autoLockMinutes: 5,163 trashRetentionDays: 30,164 theme: "dark" as const,165 accentColor: "peach" as const,166 textSize: "medium" as const,167 reduceMotion: false,168 onBreachMonitoringChange: vi.fn(),169 onLockOnCloseChange: vi.fn(),170 onAutoLockMinutesChange: vi.fn(),171 onTrashRetentionChange: vi.fn(),172 onThemeChange: vi.fn(),173 onAccentChange: vi.fn(),174 onTextSizeChange: vi.fn(),175 onReduceMotionChange: vi.fn(),176 shortcuts: DEFAULT_BINDINGS,177 onShortcutsChange: vi.fn(),178 onBack: vi.fn(),179 onImport: vi.fn(),180 onExport: vi.fn(),181 onBreachCheckDone: vi.fn(),182 ...overrides,183 };184 }185186 async function selectImportFile(187 container: HTMLElement,188 contents = '{"export":"test"}'189 ) {190 191 192 193 const fileCard = findButton(container, "Choose file →");194 if (fileCard) {195 fileCard.click();196 await flushView();197 }198 const input = container.querySelector('input[type="file"]') as HTMLInputElement;199 const file = new File([contents], "import.json", { type: "application/json" });200 Object.defineProperty(input, "files", {201 configurable: true,202 value: [file],203 });204 input.dispatchEvent(new Event("change", { bubbles: true }));205 await flushView();206 await flushView();207 }208209 function findButton(container: HTMLElement, text: string): HTMLButtonElement {210 return Array.from(container.querySelectorAll("button")).find(211 (candidate) => candidate.textContent?.trim() === text212 ) as HTMLButtonElement;213 }214215 it("Lock sends UNLOCK with the entered passphrase", async () => {216 sendMessageMock.mockResolvedValue({ ok: true });217 const onUnlock = vi.fn(async () => undefined);218 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);219220 const password = container.querySelector("#unlock-password") as HTMLInputElement;221 inputText(password, "correct horse battery staple");222 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();223 await flushView();224225 expect(sendMessageMock).toHaveBeenCalledWith({226 type: "UNLOCK",227 payload: { password: "correct horse battery staple" },228 });229 expect(onUnlock).toHaveBeenCalled();230 });231232 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {233 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);234 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;235 const password = container.querySelector("#master-password") as HTMLInputElement;236 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;237238 inputText(password, "short");239 inputText(confirm, "short");240 await flushView();241 expect(createButton.disabled).toBe(true);242243 zxcvbnMock.mockImplementation(() => ({ score: 1 }));244 inputText(password, "weakweakweak");245 inputText(confirm, "weakweakweak");246 await flushView();247 expect(createButton.disabled).toBe(true);248 });249250 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {251 252 sendMessageMock.mockImplementation(253 async (msg: unknown) => {254 const m = msg as { type: string; payload?: { password?: string } };255 if (m.type === "CREATE_VAULT") return { ok: true };256 if (m.type === "GET_RECOVERY_WORDS") {257 258 return {259 ok: true,260 data: [261 "abandon", "ability", "able", "about", "above", "absent",262 "absorb", "abstract", "absurd", "abuse", "access", "accident",263 ],264 };265 }266 return { ok: true };267 }268 );269 zxcvbnMock.mockImplementation(() => ({ score: 3 }));270 const onUnlock = vi.fn(async () => undefined);271 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);272273 const password = container.querySelector("#master-password") as HTMLInputElement;274 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;275 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276277 inputText(password, "correct horse battery staple");278 inputText(confirm, "correct horse battery staple");279 await flushView();280 expect(createButton.disabled).toBe(false);281282 pressEnter(confirm);283 await flushView();284285 expect(sendMessageMock).toHaveBeenCalledWith({286 type: "CREATE_VAULT",287 payload: { password: "correct horse battery staple" },288 });289 290 291 expect(onUnlock).not.toHaveBeenCalled();292 });293294 it("Recovery words flow submits the new master password and completes", async () => {295 sendMessageMock.mockImplementation(async (msg: unknown) => {296 const type = (msg as { type?: string }).type;297 if (type === "VALIDATE_RECOVERY_WORDS") {298 return { ok: true, data: { valid: true, invalid: [] } };299 }300 if (type === "RECOVER_WITH_WORDS") return { ok: true };301 return { ok: true };302 });303 const canvasContext = {304 clearRect: vi.fn(),305 fillText: vi.fn(),306 beginPath: vi.fn(),307 closePath: vi.fn(),308 arc: vi.fn(),309 fill: vi.fn(),310 stroke: vi.fn(),311 save: vi.fn(),312 restore: vi.fn(),313 translate: vi.fn(),314 rotate: vi.fn(),315 scale: vi.fn(),316 drawImage: vi.fn(),317 measureText: vi.fn(() => ({ width: 0 })),318 setTransform: vi.fn(),319 fillRect: vi.fn(),320 strokeRect: vi.fn(),321 } as unknown as CanvasRenderingContext2D;322 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);323324 const onRecoveryComplete = vi.fn(async () => undefined);325 const container = mount(326 <RecoveryWordEntryView327 onCancel={vi.fn()}328 onRecoveryComplete={onRecoveryComplete}329 />330 );331332 const recoveryWords = WORD_LIST.slice(0, 12);333 for (let i = 0; i < recoveryWords.length; i++) {334 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;335 inputText(input, recoveryWords[i]!);336 }337 await flushView();338339 findButton(container, "verify words").click();340 await flushView();341342 expect(container.textContent).toContain("new master password");343344 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;345 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;346 inputText(newPassword, "fresh-recovery-password");347 inputText(confirmPassword, "fresh-recovery-password");348 await flushView();349350 findButton(container, "recover vault").click();351 await flushView();352353 expect(sendMessageMock).toHaveBeenCalledWith({354 type: "RECOVER_WITH_WORDS",355 payload: {356 words: recoveryWords,357 newPassword: "fresh-recovery-password",358 },359 });360 expect(onRecoveryComplete).toHaveBeenCalled();361 });362363 it("Export menu surfaces both backup formats", async () => {364 const container = mount(<ExportView onBack={vi.fn()} />);365 await flushView();366367 expect(container.textContent).toContain("PeachScript");368 expect(container.textContent).toContain("CSV");369 });370371 it("Lock onboarding restore options include Peach Codex", async () => {372 const onOpenCodexRestore = vi.fn();373 const container = mount(374 <LockView375 hasVault={false}376 ready={true}377 onUnlock={vi.fn()}378 onOpenCodexRestore={onOpenCodexRestore}379 />380 );381382 const restoreButton = Array.from(container.querySelectorAll("button")).find(383 (candidate) => candidate.textContent?.includes("restore from backup")384 ) as HTMLButtonElement;385 restoreButton.click();386 await flushView();387388 expect(container.textContent).toContain("PeachScript");389 expect(container.textContent).toContain("Codex");390 expect(container.textContent).toContain("CSV");391392 const codexTab = Array.from(container.querySelectorAll("button")).find(393 (candidate) => candidate.textContent?.trim() === "Codex"394 ) as HTMLButtonElement;395 codexTab.click();396 await flushView();397398 expect(container.textContent).toContain("Restore from Peach Codex");399 expect(container.textContent).toContain("Scan printed Codex pages");400401 const openCodexButton = Array.from(container.querySelectorAll("button")).find(402 (candidate) => candidate.textContent?.trim() === "open Codex restore →"403 ) as HTMLButtonElement;404 openCodexButton.click();405406 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);407 });408409 it("Lock can open directly on backup restore from onboarding", async () => {410 const container = mount(() => (411 <LockView412 hasVault={false}413 ready={true}414 onUnlock={vi.fn()}415 initialRestore={true}416 />417 ));418 await flushView();419420 expect(container.textContent).toContain("PeachScript");421 expect(container.textContent).toContain("Codex");422 expect(container.textContent).toContain("CSV");423 expect(container.querySelector("#master-password")).toBeNull();424 });425426 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {427 const device = {428 deviceId: "phone-1",429 deviceName: "June's Phone",430 deviceType: "android" as const,431 longTermPublicKeyHex: "a".repeat(64),432 signingPublicKeyHex: "b".repeat(64),433 pairedAt: 1,434 trustSource: "direct" as const,435 };436 sendMessageMock.mockImplementation(async (msg: unknown) => {437 const m = msg as { type: string };438 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };439 return { ok: true, data: null };440 });441442 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);443 for (let i = 0; i < 30; i++) await Promise.resolve();444445 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>446 /sync/i.test(b.textContent ?? ""),447 );448 openSync?.click();449 for (let i = 0; i < 30; i++) await Promise.resolve();450451 const remove = Array.from(container.querySelectorAll("button")).find(452 (b) => (b.textContent ?? "").trim() === "remove",453 );454 expect(remove).toBeTruthy();455456 457 458 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();459460 remove!.click();461 for (let i = 0; i < 10; i++) await Promise.resolve();462463 464 465 const note = container.querySelector(".peach-unpair-confirm-note");466 expect(note).toBeTruthy();467 const text = note!.textContent ?? "";468 expect(text).toContain("master password");469 expect(text).toContain("recovery words");470 expect(text).toContain("future changes");471 });472473 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {474 const device = {475 deviceId: "phone-1",476 deviceName: "June's Phone",477 deviceType: "android" as const,478 longTermPublicKeyHex: "a".repeat(64),479 signingPublicKeyHex: "b".repeat(64),480 pairedAt: 1,481 trustSource: "direct" as const,482 };483 let paired: typeof device[] = [device];484 sendMessageMock.mockImplementation(async (msg: any) => {485 if (msg?.type === "LIST_PAIRED_DEVICES") {486 return { ok: true, data: { devices: [...paired] } };487 }488 if (msg?.type === "UNPAIR_DEVICE") {489 490 491 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);492 return { ok: true };493 }494 return { ok: true, data: null };495 });496497 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);498 for (let i = 0; i < 30; i++) await Promise.resolve();499 Array.from(container.querySelectorAll("button"))500 .find((b) => /sync/i.test(b.textContent ?? ""))501 ?.click();502 for (let i = 0; i < 30; i++) await Promise.resolve();503504 505 expect(container.textContent).toContain("PAIRED DEVICES");506 expect(container.textContent).toContain("June's Phone");507 expect(container.textContent).toContain("Android");508509 Array.from(container.querySelectorAll("button"))510 .find((b) => (b.textContent ?? "").trim() === "remove")!511 .click();512 for (let i = 0; i < 10; i++) await Promise.resolve();513 const confirm = Array.from(514 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),515 ).find((b) => (b.textContent ?? "").trim() === "remove");516 confirm!.click();517 for (let i = 0; i < 30; i++) await Promise.resolve();518519 expect(sendMessageMock).toHaveBeenCalledWith({520 type: "UNPAIR_DEVICE",521 payload: { deviceId: "phone-1" },522 });523 524 525 526 expect(container.textContent).not.toContain("June's Phone");527 expect(container.textContent).not.toContain("PAIRED DEVICES");528 });529530 it("Settings keeps connection and recovery details collapsed until requested", async () => {531 sendMessageMock.mockImplementation(async (msg: any) => {532 if (msg?.type === "LIST_PAIRED_DEVICES") {533 return { ok: true, data: { devices: [] } };534 }535 return { ok: true, data: null };536 });537538 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);539 for (let i = 0; i < 30; i++) await Promise.resolve();540 Array.from(container.querySelectorAll("button"))541 .find((button) => /sync/i.test(button.textContent ?? ""))542 ?.click();543 for (let i = 0; i < 20; i++) await Promise.resolve();544545 expect(container.textContent).toContain("SYNC STATUS");546 expect(container.textContent).toContain("ADD A DEVICE");547 expect(container.textContent).not.toContain("Public relay");548 expect(container.textContent).not.toContain("Rotate sync identity");549550 const connection = Array.from(container.querySelectorAll("button"))551 .find((button) => button.textContent?.includes("CONNECTION"));552 connection?.click();553 await Promise.resolve();554 expect(container.textContent).toContain("Public relay");555556 const recovery = Array.from(container.querySelectorAll("button"))557 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));558 recovery?.click();559 await Promise.resolve();560 expect(container.textContent).toContain("Rotate sync identity");561 });562563 it("Settings requires current, new, and confirm passwords before enabling save", async () => {564 sendMessageMock.mockResolvedValue({ ok: true, data: null });565 const container = mount(566 <SettingsView567 entriesCount={1}568 breachMonitoring={true}569 lockOnClose={true}570 autoLockMinutes={5}571 trashRetentionDays={30}572 theme="dark"573 accentColor="peach"574 textSize="medium"575 reduceMotion={false}576 onBreachMonitoringChange={vi.fn()}577 onLockOnCloseChange={vi.fn()}578 onAutoLockMinutesChange={vi.fn()}579 onTrashRetentionChange={vi.fn()}580 onThemeChange={vi.fn()}581 onAccentChange={vi.fn()}582 onTextSizeChange={vi.fn()}583 onReduceMotionChange={vi.fn()}584 shortcuts={DEFAULT_BINDINGS}585 onShortcutsChange={vi.fn()}586 onBack={vi.fn()}587 onImport={vi.fn()}588 onExport={vi.fn()}589 onBreachCheckDone={vi.fn()}590 />591 );592 await flushView();593594 expect(container.querySelectorAll(".stk-card").length).toBe(7);595 596 597 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");598599 const changeButton = Array.from(container.querySelectorAll("button")).find(600 (candidate) => candidate.textContent?.trim() === "change"601 ) as HTMLButtonElement;602 changeButton.click();603 await flushView();604605 const saveButton = Array.from(container.querySelectorAll("button")).find(606 (candidate) => candidate.textContent?.trim() === "save new password"607 ) as HTMLButtonElement;608 expect(saveButton.disabled).toBe(true);609610 const inputs = container.querySelectorAll('input[type="password"]');611 inputText(inputs[0] as HTMLInputElement, "current-pass");612 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");613 await flushView();614 expect(saveButton.disabled).toBe(true);615616 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");617 await flushView();618 expect(saveButton.disabled).toBe(false);619 });620621 it("Settings feedback highlights the selected category and hides the form after submit", async () => {622 sendMessageMock.mockResolvedValue({ ok: true, data: null });623 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({624 ok: true,625 json: async () => ({ success: true }),626 }));627 vi.stubGlobal("fetch", fetchMock);628629 const container = mount(630 <SettingsView631 entriesCount={1}632 breachMonitoring={true}633 lockOnClose={true}634 autoLockMinutes={5}635 trashRetentionDays={30}636 theme="dark"637 accentColor="peach"638 textSize="medium"639 reduceMotion={false}640 initialSection="feedback"641 onBreachMonitoringChange={vi.fn()}642 onLockOnCloseChange={vi.fn()}643 onAutoLockMinutesChange={vi.fn()}644 onTrashRetentionChange={vi.fn()}645 onThemeChange={vi.fn()}646 onAccentChange={vi.fn()}647 onTextSizeChange={vi.fn()}648 onReduceMotionChange={vi.fn()}649 shortcuts={DEFAULT_BINDINGS}650 onShortcutsChange={vi.fn()}651 onBack={vi.fn()}652 onImport={vi.fn()}653 onExport={vi.fn()}654 onBreachCheckDone={vi.fn()}655 />656 );657 await flushView();658659 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;660 expect(feedbackCard.classList.contains("is-active")).toBe(true);661662 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];663 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;664 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;665 expect(bugButton.getAttribute("aria-pressed")).toBe("true");666 expect(featureButton.getAttribute("aria-pressed")).toBe("false");667668 featureButton.click();669 await flushView();670671 expect(bugButton.getAttribute("aria-pressed")).toBe("false");672 expect(featureButton.getAttribute("aria-pressed")).toBe("true");673 expect(featureButton.classList.contains("is-active")).toBe(true);674675 inputText(676 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,677 '<script>alert("xss")</script> Please make this easier to see & safer.'678 );679 const sendButton = Array.from(container.querySelectorAll("button")).find(680 (candidate) => candidate.textContent?.trim() === "Send"681 ) as HTMLButtonElement;682 sendButton.click();683684 await vi.waitFor(() => {685 expect(container.textContent).toContain("Thank you");686 });687688 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));689 expect(requestBody.subject).toContain("Feature request");690 expect(requestBody.message).toBe(691 "<script>alert("xss")</script> Please make this easier to see & safer."692 );693 expect(requestBody.message).not.toContain("<script>");694 expect(container.textContent).toContain("Someone will process it soon");695 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();696 expect(container.querySelector(".peach-feedback-submit")).toBeNull();697 });698699 it("Settings keeps license activation on the full-screen celebration path", async () => {700 const onLicenseChange = vi.fn(async () => undefined);701 sendMessageMock.mockImplementation(async (msg: unknown) => {702 const type = (msg as { type?: string }).type;703 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };704 if (type === "ACTIVATE_LICENSE") {705 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };706 }707 if (type === "GET_ENTRIES") return { ok: true, data: [] };708 return { ok: true, data: null };709 });710711 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);712 await flushView();713714 inputText(715 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,716 "PEACH-TEST-KEY",717 );718 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;719 apply.click();720721 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));722 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);723 expect(container.textContent).not.toContain("thank you ✦");724 });725726 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {727 sendMessageMock.mockImplementation(async (msg: unknown) => {728 const type = (msg as { type?: string }).type;729 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };730 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };731 if (type === "GET_ENTRIES") return { ok: true, data: [] };732 return { ok: true, data: null };733 });734735 const onLicenseChangeFromStar = vi.fn();736 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);737 await flushView();738 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();739 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);740 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();741742 const onLicenseChangeFromPeach = vi.fn();743 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);744 await flushView();745 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();746 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);747 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();748 });749750 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {751 sendMessageMock.mockResolvedValue({ ok: true, data: null });752 const fetchMock = vi.fn();753 const requestMock = vi.fn(async () => false);754 vi.stubGlobal("fetch", fetchMock);755 vi.stubGlobal("browser", {756 permissions: {757 getAll: vi.fn(async () => ({ data_collection: [] })),758 request: requestMock,759 },760 });761762 const container = mount(763 <SettingsView764 entriesCount={1}765 breachMonitoring={true}766 lockOnClose={true}767 autoLockMinutes={5}768 trashRetentionDays={30}769 theme="dark"770 accentColor="peach"771 textSize="medium"772 reduceMotion={false}773 initialSection="feedback"774 onBreachMonitoringChange={vi.fn()}775 onLockOnCloseChange={vi.fn()}776 onAutoLockMinutesChange={vi.fn()}777 onTrashRetentionChange={vi.fn()}778 onThemeChange={vi.fn()}779 onAccentChange={vi.fn()}780 onTextSizeChange={vi.fn()}781 onReduceMotionChange={vi.fn()}782 shortcuts={DEFAULT_BINDINGS}783 onShortcutsChange={vi.fn()}784 onBack={vi.fn()}785 onImport={vi.fn()}786 onExport={vi.fn()}787 onBreachCheckDone={vi.fn()}788 />789 );790 await flushView();791792 inputText(793 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,794 "Please improve the feedback flow."795 );796 const sendButton = Array.from(container.querySelectorAll("button")).find(797 (candidate) => candidate.textContent?.trim() === "Send"798 ) as HTMLButtonElement;799 sendButton.click();800801 await vi.waitFor(() => {802 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });803 });804 expect(fetchMock).not.toHaveBeenCalled();805 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();806 });807808 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {809 sendMessageMock.mockImplementation(async (msg: unknown) => {810 const m = msg as { type: string };811 if (m.type === "GET_RECOVERY_WORDS") {812 return {813 ok: true,814 data: [815 "abandon", "ability", "able", "about", "above", "absent",816 "absorb", "abstract", "absurd", "abuse", "access", "accident",817 ],818 };819 }820 return { ok: true, data: null };821 });822 const onAutoLockMinutesChange = vi.fn();823 const onThemeChange = vi.fn();824 const container = mount(825 <SettingsView826 entriesCount={1}827 breachMonitoring={true}828 lockOnClose={true}829 autoLockMinutes={5}830 trashRetentionDays={30}831 theme="dark"832 accentColor="peach"833 textSize="medium"834 reduceMotion={false}835 onBreachMonitoringChange={vi.fn()}836 onLockOnCloseChange={vi.fn()}837 onAutoLockMinutesChange={onAutoLockMinutesChange}838 onTrashRetentionChange={vi.fn()}839 onThemeChange={onThemeChange}840 onAccentChange={vi.fn()}841 onTextSizeChange={vi.fn()}842 onReduceMotionChange={vi.fn()}843 shortcuts={DEFAULT_BINDINGS}844 onShortcutsChange={vi.fn()}845 onBack={vi.fn()}846 onImport={vi.fn()}847 onExport={vi.fn()}848 onBreachCheckDone={vi.fn()}849 />850 );851 await flushView();852 sendMessageMock.mockClear();853854 855 const selects = container.querySelectorAll("select");856 changeSelect(selects[0] as HTMLSelectElement, "15");857 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);858859 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;860 appearanceCard.click();861 await flushView();862 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");863 const themeButton = Array.from(container.querySelectorAll("button")).find(864 (candidate) => candidate.textContent?.trim() === "Switch to light"865 ) as HTMLButtonElement;866 themeButton.click();867 expect(onThemeChange).toHaveBeenCalledWith("light");868869 870 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;871 protectionCard.click();872 await flushView();873874 const changeButton = Array.from(container.querySelectorAll("button")).find(875 (candidate) => candidate.textContent?.trim() === "change"876 ) as HTMLButtonElement;877 changeButton.click();878 await flushView();879880 const inputs = container.querySelectorAll('input[type="password"]');881 inputText(inputs[0] as HTMLInputElement, "current-pass");882 inputText(inputs[1] as HTMLInputElement, "new-password-123");883 inputText(inputs[2] as HTMLInputElement, "new-password-123");884 await flushView();885886 const saveButton = Array.from(container.querySelectorAll("button")).find(887 (candidate) => candidate.textContent?.trim() === "save new password"888 ) as HTMLButtonElement;889 saveButton.click();890 await flushView();891892 expect(sendMessageMock).toHaveBeenCalledWith({893 type: "CHANGE_PASSWORD",894 payload: {895 currentPassword: "current-pass",896 newPassword: "new-password-123",897 },898 });899900 901 expect(container.textContent).toContain("save your recovery words");902 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });903 });904905 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {906 sendMessageMock.mockResolvedValue({ ok: true });907 const container = mount(908 <EntryView909 entry={sampleEntry()}910 onDelete={vi.fn()}911 onFieldSaved={vi.fn(async () => undefined)}912 />913 );914915 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;916 passwordButton.click();917 await flushView();918919 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {920 type: "COPY",921 payload: { text: "secret123", label: "password", alreadyWritten: false },922 });923 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {924 type: "FIRE_CANARY_PING",925 payload: {926 webhookUrl: "https://example.com/hook",927 entryTitle: "GitHub",928 trigger: "password_copied",929 },930 });931932 sendMessageMock.mockClear();933 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;934 usernameButton.click();935 await flushView();936937 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {938 type: "COPY",939 payload: { text: "june", label: "username", alreadyWritten: false },940 });941 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {942 type: "FIRE_CANARY_PING",943 payload: {944 webhookUrl: "https://example.com/hook",945 entryTitle: "GitHub",946 trigger: "username_copied",947 },948 });949 });950951 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {952 sendMessageMock.mockResolvedValue({ ok: true });953 const onFieldSaved = vi.fn(async () => undefined);954 const container = mount(955 <EntryView956 entry={sampleEntry()}957 onDelete={vi.fn()}958 onFieldSaved={onFieldSaved}959 />960 );961962 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;963 passwordValue.click();964 await flushView();965966 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;967 inputText(passwordInput, "");968 await flushView();969970 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;971 saveButton.click();972 await flushView();973974 expect(sendMessageMock).not.toHaveBeenCalledWith(975 expect.objectContaining({ type: "UPDATE_ENTRY" })976 );977 expect(onFieldSaved).not.toHaveBeenCalled();978979 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;980 totpMain.click();981982 await vi.waitFor(() => {983 expect(sendMessageMock).toHaveBeenLastCalledWith({984 type: "COPY",985 payload: { text: "123456", label: "totp", alreadyWritten: false },986 });987 }, { timeout: 500 });988 });989990 it("Entry closes an unchanged inline editor when focus leaves it", async () => {991 sendMessageMock.mockResolvedValue({ ok: true });992 const container = mount(993 <EntryView994 entry={sampleEntry()}995 onDelete={vi.fn()}996 onFieldSaved={vi.fn(async () => undefined)}997 />998 );9991000 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1001 usernameValue.click();1002 await flushView();10031004 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1005 usernameInput.blur();1006 await flushView();10071008 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1009 expect(sendMessageMock).not.toHaveBeenCalledWith(1010 expect.objectContaining({ type: "UPDATE_ENTRY" })1011 );1012 });10131014 it("Entry lets TOTP secrets be edited inline", async () => {1015 sendMessageMock.mockResolvedValue({ ok: true });1016 const onFieldSaved = vi.fn(async () => undefined);1017 const container = mount(1018 <EntryView1019 entry={sampleEntry()}1020 onDelete={vi.fn()}1021 onFieldSaved={onFieldSaved}1022 />1023 );10241025 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1026 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1027 await flushView();10281029 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1030 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1031 await flushView();10321033 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1034 saveButton.click();1035 await flushView();10361037 expect(sendMessageMock).toHaveBeenCalledWith({1038 type: "UPDATE_ENTRY",1039 payload: {1040 id: "entry-1",1041 patch: { totp: "NB2W45DFOIZA====" },1042 },1043 });1044 expect(onFieldSaved).toHaveBeenCalled();1045 });10461047 it("Vault seeds and focuses search when a search seed is provided", async () => {1048 const container = mount(1049 <VaultView1050 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1051 sortOrder="url-first"1052 searchSeed="g"1053 searchSeedNonce={1}1054 onSelectEntry={vi.fn()}1055 onSortOrderChange={vi.fn()}1056 onAdd={vi.fn()}1057 onClose={vi.fn()}1058 />1059 );10601061 await flushView();10621063 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1064 expect(search.value).toBe("g");1065 expect(document.activeElement).toBe(search);1066 });10671068 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1069 parseImportSourceMock.mockResolvedValue(1070 sampleImportResult({1071 entries: [1072 {1073 type: "login",1074 title: "Imported site",1075 username: "imported-user",1076 password: "imported-pass",1077 url: "https://imported.test",1078 },1079 ],1080 })1081 );1082 sendMessageMock.mockImplementation(async (msg: unknown) => {1083 const type = (msg as { type?: string }).type;1084 if (type === "GET_ENTRIES") {1085 return {1086 ok: true,1087 data: [1088 sampleEntry({1089 id: "existing-1",1090 title: "Imported site",1091 username: "imported-user",1092 url: "https://imported.test",1093 }),1094 ],1095 };1096 }1097 if (type === "BATCH_ADD_ENTRIES") {1098 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1099 }1100 return { ok: true };1101 });1102 const onImport = vi.fn(async () => undefined);1103 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1104 await flushView();1105 sendMessageMock.mockClear();11061107 await selectImportFile(container);1108 findButton(container, "Import 1").click();1109 await flushView();11101111 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1112 expect(sendMessageMock).toHaveBeenCalledWith({1113 type: "BATCH_ADD_ENTRIES",1114 payload: {1115 entries: [1116 expect.objectContaining({1117 title: "Imported site",1118 username: "imported-user",1119 password: "imported-pass",1120 url: "https://imported.test",1121 }),1122 ],1123 },1124 });1125 expect(onImport).toHaveBeenCalled();1126 expect(container.textContent).toContain("1 entry appears to be duplicates");1127 });11281129 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1130 parseImportSourceMock.mockResolvedValue(sampleImportResult());1131 sendMessageMock.mockImplementation(async (msg: unknown) => {1132 const type = (msg as { type?: string }).type;1133 if (type === "GET_ENTRIES") {1134 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1135 }1136 if (type === "CLEAR_VAULT_ENTRIES") {1137 return { ok: true };1138 }1139 if (type === "BATCH_ADD_ENTRIES") {1140 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1141 }1142 return { ok: true };1143 });1144 const onImport = vi.fn(async () => undefined);1145 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1146 await flushView();1147 sendMessageMock.mockClear();11481149 await selectImportFile(container);1150 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1151 await flushView();11521153 findButton(container, "Replace vault with 1").click();1154 await flushView();11551156 expect(sendMessageMock).not.toHaveBeenCalledWith(1157 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1158 );1159 expect(container.textContent).toContain("Replace forever? This cannot be undone.");11601161 findButton(container, "Yes, replace forever").click();1162 await flushView();11631164 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1165 expect(sendMessageMock).toHaveBeenCalledWith({1166 type: "BATCH_ADD_ENTRIES",1167 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1168 });1169 expect(onImport).toHaveBeenCalled();1170 });11711172 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1173 parseImportSourceMock.mockResolvedValue(sampleImportResult());1174 sendMessageMock.mockImplementation(async (msg: unknown) => {1175 const type = (msg as { type?: string }).type;1176 if (type === "CREATE_VAULT") return { ok: true };1177 if (type === "BATCH_ADD_ENTRIES") {1178 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1179 }1180 return { ok: true };1181 });1182 const onImport = vi.fn(async () => undefined);1183 const container = mount(1184 <ImportView1185 mode="onboarding"1186 replacesExistingVault={true}1187 onBack={vi.fn()}1188 onImport={onImport}1189 />1190 );11911192 await selectImportFile(container);11931194 const createButton = findButton(container, "Create vault + import 1");1195 expect(createButton.disabled).toBe(true);1196 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1197 expect(container.textContent).toContain("Use at least 8 characters");11981199 const masterInput = container.querySelector(1200 'input[placeholder="new master password"]'1201 ) as HTMLInputElement;1202 const confirmInput = container.querySelector(1203 'input[placeholder="confirm master password"]'1204 ) as HTMLInputElement;1205 const revealButton = container.querySelector(1206 'button[aria-label="Show passwords"]'1207 ) as HTMLButtonElement;1208 expect(revealButton).toBeTruthy();1209 revealButton.click();1210 await flushView();1211 expect(masterInput.type).toBe("text");1212 expect(confirmInput.type).toBe("text");12131214 inputText(masterInput, "fresh-master-password");1215 inputText(confirmInput, "fresh-master-password");1216 await flushView();12171218 expect(createButton.disabled).toBe(false);1219 createButton.click();1220 await flushView();12211222 expect(sendMessageMock).toHaveBeenCalledWith({1223 type: "CREATE_VAULT",1224 payload: { password: "fresh-master-password" },1225 });1226 expect(sendMessageMock).toHaveBeenCalledWith({1227 type: "BATCH_ADD_ENTRIES",1228 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1229 });1230 expect(onImport).toHaveBeenCalled();1231 });12321233 it("Import encrypted backups reparse with the backup password before import", async () => {1234 parseImportSourceMock1235 .mockResolvedValueOnce(1236 sampleImportResult({1237 format: "peach-json",1238 label: "Peach encrypted backup",1239 entries: [],1240 totalItems: 0,1241 requiresPassword: true,1242 })1243 )1244 .mockResolvedValueOnce(1245 sampleImportResult({1246 format: "peach-json",1247 label: "Peach encrypted backup",1248 })1249 );1250 sendMessageMock.mockImplementation(async (msg: unknown) => {1251 const type = (msg as { type?: string }).type;1252 if (type === "GET_ENTRIES") return { ok: true, data: [] };1253 if (type === "BATCH_ADD_ENTRIES") {1254 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1255 }1256 return { ok: true };1257 });1258 const onImport = vi.fn(async () => undefined);1259 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1260 await flushView();1261 sendMessageMock.mockClear();12621263 await selectImportFile(container, '{"type":"peach-export"}');1264 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1265 inputText(password, "backup-passphrase");1266 pressEnter(password);1267 await flushView();1268 await flushView();12691270 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1271 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12721273 findButton(container, "Import 1").click();1274 await flushView();12751276 expect(sendMessageMock).toHaveBeenCalledWith({1277 type: "BATCH_ADD_ENTRIES",1278 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1279 });1280 expect(onImport).toHaveBeenCalled();1281 });1282});