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) {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("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {410 const device = {411 deviceId: "phone-1",412 deviceName: "June's Phone",413 deviceType: "android" as const,414 longTermPublicKeyHex: "a".repeat(64),415 signingPublicKeyHex: "b".repeat(64),416 pairedAt: 1,417 trustSource: "direct" as const,418 };419 sendMessageMock.mockImplementation(async (msg: unknown) => {420 const m = msg as { type: string };421 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };422 return { ok: true, data: null };423 });424425 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);426 for (let i = 0; i < 30; i++) await Promise.resolve();427428 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>429 /sync/i.test(b.textContent ?? ""),430 );431 openSync?.click();432 for (let i = 0; i < 30; i++) await Promise.resolve();433434 const remove = Array.from(container.querySelectorAll("button")).find(435 (b) => (b.textContent ?? "").trim() === "remove",436 );437 expect(remove).toBeTruthy();438439 440 441 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();442443 remove!.click();444 for (let i = 0; i < 10; i++) await Promise.resolve();445446 447 448 const note = container.querySelector(".peach-unpair-confirm-note");449 expect(note).toBeTruthy();450 const text = note!.textContent ?? "";451 expect(text).toContain("master password");452 expect(text).toContain("recovery words");453 expect(text).toContain("future changes");454 });455456 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {457 const device = {458 deviceId: "phone-1",459 deviceName: "June's Phone",460 deviceType: "android" as const,461 longTermPublicKeyHex: "a".repeat(64),462 signingPublicKeyHex: "b".repeat(64),463 pairedAt: 1,464 trustSource: "direct" as const,465 };466 let paired: typeof device[] = [device];467 sendMessageMock.mockImplementation(async (msg: any) => {468 if (msg?.type === "LIST_PAIRED_DEVICES") {469 return { ok: true, data: { devices: [...paired] } };470 }471 if (msg?.type === "UNPAIR_DEVICE") {472 473 474 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);475 return { ok: true };476 }477 return { ok: true, data: null };478 });479480 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);481 for (let i = 0; i < 30; i++) await Promise.resolve();482 Array.from(container.querySelectorAll("button"))483 .find((b) => /sync/i.test(b.textContent ?? ""))484 ?.click();485 for (let i = 0; i < 30; i++) await Promise.resolve();486487 488 expect(container.textContent).toContain("PAIRED DEVICES");489 expect(container.textContent).toContain("June's Phone");490 expect(container.textContent).toContain("Android");491492 Array.from(container.querySelectorAll("button"))493 .find((b) => (b.textContent ?? "").trim() === "remove")!494 .click();495 for (let i = 0; i < 10; i++) await Promise.resolve();496 const confirm = Array.from(497 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),498 ).find((b) => (b.textContent ?? "").trim() === "remove");499 confirm!.click();500 for (let i = 0; i < 30; i++) await Promise.resolve();501502 expect(sendMessageMock).toHaveBeenCalledWith({503 type: "UNPAIR_DEVICE",504 payload: { deviceId: "phone-1" },505 });506 507 508 509 expect(container.textContent).not.toContain("June's Phone");510 expect(container.textContent).not.toContain("PAIRED DEVICES");511 });512513 it("Settings keeps connection and recovery details collapsed until requested", async () => {514 sendMessageMock.mockImplementation(async (msg: any) => {515 if (msg?.type === "LIST_PAIRED_DEVICES") {516 return { ok: true, data: { devices: [] } };517 }518 return { ok: true, data: null };519 });520521 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);522 for (let i = 0; i < 30; i++) await Promise.resolve();523 Array.from(container.querySelectorAll("button"))524 .find((button) => /sync/i.test(button.textContent ?? ""))525 ?.click();526 for (let i = 0; i < 20; i++) await Promise.resolve();527528 expect(container.textContent).toContain("SYNC STATUS");529 expect(container.textContent).toContain("ADD A DEVICE");530 expect(container.textContent).not.toContain("Public relay");531 expect(container.textContent).not.toContain("Rotate sync identity");532533 const connection = Array.from(container.querySelectorAll("button"))534 .find((button) => button.textContent?.includes("CONNECTION"));535 connection?.click();536 await Promise.resolve();537 expect(container.textContent).toContain("Public relay");538539 const recovery = Array.from(container.querySelectorAll("button"))540 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));541 recovery?.click();542 await Promise.resolve();543 expect(container.textContent).toContain("Rotate sync identity");544 });545546 it("Settings requires current, new, and confirm passwords before enabling save", async () => {547 sendMessageMock.mockResolvedValue({ ok: true, data: null });548 const container = mount(549 <SettingsView550 entriesCount={1}551 breachMonitoring={true}552 lockOnClose={true}553 autoLockMinutes={5}554 trashRetentionDays={30}555 theme="dark"556 accentColor="peach"557 textSize="medium"558 reduceMotion={false}559 onBreachMonitoringChange={vi.fn()}560 onLockOnCloseChange={vi.fn()}561 onAutoLockMinutesChange={vi.fn()}562 onTrashRetentionChange={vi.fn()}563 onThemeChange={vi.fn()}564 onAccentChange={vi.fn()}565 onTextSizeChange={vi.fn()}566 onReduceMotionChange={vi.fn()}567 shortcuts={DEFAULT_BINDINGS}568 onShortcutsChange={vi.fn()}569 onBack={vi.fn()}570 onImport={vi.fn()}571 onExport={vi.fn()}572 onBreachCheckDone={vi.fn()}573 />574 );575 await flushView();576577 expect(container.querySelectorAll(".stk-card").length).toBe(7);578 579 580 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");581582 const changeButton = Array.from(container.querySelectorAll("button")).find(583 (candidate) => candidate.textContent?.trim() === "change"584 ) as HTMLButtonElement;585 changeButton.click();586 await flushView();587588 const saveButton = Array.from(container.querySelectorAll("button")).find(589 (candidate) => candidate.textContent?.trim() === "save new password"590 ) as HTMLButtonElement;591 expect(saveButton.disabled).toBe(true);592593 const inputs = container.querySelectorAll('input[type="password"]');594 inputText(inputs[0] as HTMLInputElement, "current-pass");595 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");596 await flushView();597 expect(saveButton.disabled).toBe(true);598599 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");600 await flushView();601 expect(saveButton.disabled).toBe(false);602 });603604 it("Settings feedback highlights the selected category and hides the form after submit", async () => {605 sendMessageMock.mockResolvedValue({ ok: true, data: null });606 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({607 ok: true,608 json: async () => ({ success: true }),609 }));610 vi.stubGlobal("fetch", fetchMock);611612 const container = mount(613 <SettingsView614 entriesCount={1}615 breachMonitoring={true}616 lockOnClose={true}617 autoLockMinutes={5}618 trashRetentionDays={30}619 theme="dark"620 accentColor="peach"621 textSize="medium"622 reduceMotion={false}623 initialSection="feedback"624 onBreachMonitoringChange={vi.fn()}625 onLockOnCloseChange={vi.fn()}626 onAutoLockMinutesChange={vi.fn()}627 onTrashRetentionChange={vi.fn()}628 onThemeChange={vi.fn()}629 onAccentChange={vi.fn()}630 onTextSizeChange={vi.fn()}631 onReduceMotionChange={vi.fn()}632 shortcuts={DEFAULT_BINDINGS}633 onShortcutsChange={vi.fn()}634 onBack={vi.fn()}635 onImport={vi.fn()}636 onExport={vi.fn()}637 onBreachCheckDone={vi.fn()}638 />639 );640 await flushView();641642 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;643 expect(feedbackCard.classList.contains("is-active")).toBe(true);644645 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];646 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;647 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;648 expect(bugButton.getAttribute("aria-pressed")).toBe("true");649 expect(featureButton.getAttribute("aria-pressed")).toBe("false");650651 featureButton.click();652 await flushView();653654 expect(bugButton.getAttribute("aria-pressed")).toBe("false");655 expect(featureButton.getAttribute("aria-pressed")).toBe("true");656 expect(featureButton.classList.contains("is-active")).toBe(true);657658 inputText(659 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,660 '<script>alert("xss")</script> Please make this easier to see & safer.'661 );662 const sendButton = Array.from(container.querySelectorAll("button")).find(663 (candidate) => candidate.textContent?.trim() === "Send"664 ) as HTMLButtonElement;665 sendButton.click();666667 await vi.waitFor(() => {668 expect(container.textContent).toContain("Thank you");669 });670671 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));672 expect(requestBody.subject).toContain("Feature request");673 expect(requestBody.message).toBe(674 "<script>alert("xss")</script> Please make this easier to see & safer."675 );676 expect(requestBody.message).not.toContain("<script>");677 expect(container.textContent).toContain("Someone will process it soon");678 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();679 expect(container.querySelector(".peach-feedback-submit")).toBeNull();680 });681682 it("Settings keeps license activation on the full-screen celebration path", async () => {683 const onLicenseChange = vi.fn(async () => undefined);684 sendMessageMock.mockImplementation(async (msg: unknown) => {685 const type = (msg as { type?: string }).type;686 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };687 if (type === "ACTIVATE_LICENSE") {688 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };689 }690 if (type === "GET_ENTRIES") return { ok: true, data: [] };691 return { ok: true, data: null };692 });693694 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);695 await flushView();696697 inputText(698 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,699 "PEACH-TEST-KEY",700 );701 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;702 apply.click();703704 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));705 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);706 expect(container.textContent).not.toContain("thank you ✦");707 });708709 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {710 sendMessageMock.mockImplementation(async (msg: unknown) => {711 const type = (msg as { type?: string }).type;712 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };713 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };714 if (type === "GET_ENTRIES") return { ok: true, data: [] };715 return { ok: true, data: null };716 });717718 const onLicenseChangeFromStar = vi.fn();719 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);720 await flushView();721 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();722 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);723 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();724725 const onLicenseChangeFromPeach = vi.fn();726 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);727 await flushView();728 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();729 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);730 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();731 });732733 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {734 sendMessageMock.mockResolvedValue({ ok: true, data: null });735 const fetchMock = vi.fn();736 const requestMock = vi.fn(async () => false);737 vi.stubGlobal("fetch", fetchMock);738 vi.stubGlobal("browser", {739 permissions: {740 getAll: vi.fn(async () => ({ data_collection: [] })),741 request: requestMock,742 },743 });744745 const container = mount(746 <SettingsView747 entriesCount={1}748 breachMonitoring={true}749 lockOnClose={true}750 autoLockMinutes={5}751 trashRetentionDays={30}752 theme="dark"753 accentColor="peach"754 textSize="medium"755 reduceMotion={false}756 initialSection="feedback"757 onBreachMonitoringChange={vi.fn()}758 onLockOnCloseChange={vi.fn()}759 onAutoLockMinutesChange={vi.fn()}760 onTrashRetentionChange={vi.fn()}761 onThemeChange={vi.fn()}762 onAccentChange={vi.fn()}763 onTextSizeChange={vi.fn()}764 onReduceMotionChange={vi.fn()}765 shortcuts={DEFAULT_BINDINGS}766 onShortcutsChange={vi.fn()}767 onBack={vi.fn()}768 onImport={vi.fn()}769 onExport={vi.fn()}770 onBreachCheckDone={vi.fn()}771 />772 );773 await flushView();774775 inputText(776 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,777 "Please improve the feedback flow."778 );779 const sendButton = Array.from(container.querySelectorAll("button")).find(780 (candidate) => candidate.textContent?.trim() === "Send"781 ) as HTMLButtonElement;782 sendButton.click();783784 await vi.waitFor(() => {785 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });786 });787 expect(fetchMock).not.toHaveBeenCalled();788 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();789 });790791 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {792 sendMessageMock.mockImplementation(async (msg: unknown) => {793 const m = msg as { type: string };794 if (m.type === "GET_RECOVERY_WORDS") {795 return {796 ok: true,797 data: [798 "abandon", "ability", "able", "about", "above", "absent",799 "absorb", "abstract", "absurd", "abuse", "access", "accident",800 ],801 };802 }803 return { ok: true, data: null };804 });805 const onAutoLockMinutesChange = vi.fn();806 const onThemeChange = vi.fn();807 const container = mount(808 <SettingsView809 entriesCount={1}810 breachMonitoring={true}811 lockOnClose={true}812 autoLockMinutes={5}813 trashRetentionDays={30}814 theme="dark"815 accentColor="peach"816 textSize="medium"817 reduceMotion={false}818 onBreachMonitoringChange={vi.fn()}819 onLockOnCloseChange={vi.fn()}820 onAutoLockMinutesChange={onAutoLockMinutesChange}821 onTrashRetentionChange={vi.fn()}822 onThemeChange={onThemeChange}823 onAccentChange={vi.fn()}824 onTextSizeChange={vi.fn()}825 onReduceMotionChange={vi.fn()}826 shortcuts={DEFAULT_BINDINGS}827 onShortcutsChange={vi.fn()}828 onBack={vi.fn()}829 onImport={vi.fn()}830 onExport={vi.fn()}831 onBreachCheckDone={vi.fn()}832 />833 );834 await flushView();835 sendMessageMock.mockClear();836837 838 const selects = container.querySelectorAll("select");839 changeSelect(selects[0] as HTMLSelectElement, "15");840 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);841842 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;843 appearanceCard.click();844 await flushView();845 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");846 const themeButton = Array.from(container.querySelectorAll("button")).find(847 (candidate) => candidate.textContent?.trim() === "Switch to light"848 ) as HTMLButtonElement;849 themeButton.click();850 expect(onThemeChange).toHaveBeenCalledWith("light");851852 853 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;854 protectionCard.click();855 await flushView();856857 const changeButton = Array.from(container.querySelectorAll("button")).find(858 (candidate) => candidate.textContent?.trim() === "change"859 ) as HTMLButtonElement;860 changeButton.click();861 await flushView();862863 const inputs = container.querySelectorAll('input[type="password"]');864 inputText(inputs[0] as HTMLInputElement, "current-pass");865 inputText(inputs[1] as HTMLInputElement, "new-password-123");866 inputText(inputs[2] as HTMLInputElement, "new-password-123");867 await flushView();868869 const saveButton = Array.from(container.querySelectorAll("button")).find(870 (candidate) => candidate.textContent?.trim() === "save new password"871 ) as HTMLButtonElement;872 saveButton.click();873 await flushView();874875 expect(sendMessageMock).toHaveBeenCalledWith({876 type: "CHANGE_PASSWORD",877 payload: {878 currentPassword: "current-pass",879 newPassword: "new-password-123",880 },881 });882883 884 expect(container.textContent).toContain("save your recovery words");885 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });886 });887888 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {889 sendMessageMock.mockResolvedValue({ ok: true });890 const container = mount(891 <EntryView892 entry={sampleEntry()}893 onDelete={vi.fn()}894 onFieldSaved={vi.fn(async () => undefined)}895 />896 );897898 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;899 passwordButton.click();900 await flushView();901902 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {903 type: "COPY",904 payload: { text: "secret123", label: "password", alreadyWritten: false },905 });906 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {907 type: "FIRE_CANARY_PING",908 payload: {909 webhookUrl: "https://example.com/hook",910 entryTitle: "GitHub",911 trigger: "password_copied",912 },913 });914915 sendMessageMock.mockClear();916 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;917 usernameButton.click();918 await flushView();919920 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {921 type: "COPY",922 payload: { text: "june", label: "username", alreadyWritten: false },923 });924 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {925 type: "FIRE_CANARY_PING",926 payload: {927 webhookUrl: "https://example.com/hook",928 entryTitle: "GitHub",929 trigger: "username_copied",930 },931 });932 });933934 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {935 sendMessageMock.mockResolvedValue({ ok: true });936 const onFieldSaved = vi.fn(async () => undefined);937 const container = mount(938 <EntryView939 entry={sampleEntry()}940 onDelete={vi.fn()}941 onFieldSaved={onFieldSaved}942 />943 );944945 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;946 passwordValue.click();947 await flushView();948949 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;950 inputText(passwordInput, "");951 await flushView();952953 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;954 saveButton.click();955 await flushView();956957 expect(sendMessageMock).not.toHaveBeenCalledWith(958 expect.objectContaining({ type: "UPDATE_ENTRY" })959 );960 expect(onFieldSaved).not.toHaveBeenCalled();961962 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;963 totpMain.click();964965 await vi.waitFor(() => {966 expect(sendMessageMock).toHaveBeenLastCalledWith({967 type: "COPY",968 payload: { text: "123456", label: "totp", alreadyWritten: false },969 });970 }, { timeout: 500 });971 });972973 it("Entry closes an unchanged inline editor when focus leaves it", async () => {974 sendMessageMock.mockResolvedValue({ ok: true });975 const container = mount(976 <EntryView977 entry={sampleEntry()}978 onDelete={vi.fn()}979 onFieldSaved={vi.fn(async () => undefined)}980 />981 );982983 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;984 usernameValue.click();985 await flushView();986987 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;988 usernameInput.blur();989 await flushView();990991 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();992 expect(sendMessageMock).not.toHaveBeenCalledWith(993 expect.objectContaining({ type: "UPDATE_ENTRY" })994 );995 });996997 it("Entry lets TOTP secrets be edited inline", async () => {998 sendMessageMock.mockResolvedValue({ ok: true });999 const onFieldSaved = vi.fn(async () => undefined);1000 const container = mount(1001 <EntryView1002 entry={sampleEntry()}1003 onDelete={vi.fn()}1004 onFieldSaved={onFieldSaved}1005 />1006 );10071008 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1009 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1010 await flushView();10111012 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1013 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1014 await flushView();10151016 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1017 saveButton.click();1018 await flushView();10191020 expect(sendMessageMock).toHaveBeenCalledWith({1021 type: "UPDATE_ENTRY",1022 payload: {1023 id: "entry-1",1024 patch: { totp: "NB2W45DFOIZA====" },1025 },1026 });1027 expect(onFieldSaved).toHaveBeenCalled();1028 });10291030 it("Vault seeds and focuses search when a search seed is provided", async () => {1031 const container = mount(1032 <VaultView1033 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1034 sortOrder="url-first"1035 searchSeed="g"1036 searchSeedNonce={1}1037 onSelectEntry={vi.fn()}1038 onSortOrderChange={vi.fn()}1039 onAdd={vi.fn()}1040 onClose={vi.fn()}1041 />1042 );10431044 await flushView();10451046 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1047 expect(search.value).toBe("g");1048 expect(document.activeElement).toBe(search);1049 });10501051 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1052 parseImportSourceMock.mockResolvedValue(1053 sampleImportResult({1054 entries: [1055 {1056 type: "login",1057 title: "Imported site",1058 username: "imported-user",1059 password: "imported-pass",1060 url: "https://imported.test",1061 },1062 ],1063 })1064 );1065 sendMessageMock.mockImplementation(async (msg: unknown) => {1066 const type = (msg as { type?: string }).type;1067 if (type === "GET_ENTRIES") {1068 return {1069 ok: true,1070 data: [1071 sampleEntry({1072 id: "existing-1",1073 title: "Imported site",1074 username: "imported-user",1075 url: "https://imported.test",1076 }),1077 ],1078 };1079 }1080 if (type === "BATCH_ADD_ENTRIES") {1081 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1082 }1083 return { ok: true };1084 });1085 const onImport = vi.fn(async () => undefined);1086 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1087 await flushView();1088 sendMessageMock.mockClear();10891090 await selectImportFile(container);1091 findButton(container, "Import 1").click();1092 await flushView();10931094 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1095 expect(sendMessageMock).toHaveBeenCalledWith({1096 type: "BATCH_ADD_ENTRIES",1097 payload: {1098 entries: [1099 expect.objectContaining({1100 title: "Imported site",1101 username: "imported-user",1102 password: "imported-pass",1103 url: "https://imported.test",1104 }),1105 ],1106 },1107 });1108 expect(onImport).toHaveBeenCalled();1109 expect(container.textContent).toContain("1 entry appears to be duplicates");1110 });11111112 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1113 parseImportSourceMock.mockResolvedValue(sampleImportResult());1114 sendMessageMock.mockImplementation(async (msg: unknown) => {1115 const type = (msg as { type?: string }).type;1116 if (type === "GET_ENTRIES") {1117 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1118 }1119 if (type === "CLEAR_VAULT_ENTRIES") {1120 return { ok: true };1121 }1122 if (type === "BATCH_ADD_ENTRIES") {1123 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1124 }1125 return { ok: true };1126 });1127 const onImport = vi.fn(async () => undefined);1128 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1129 await flushView();1130 sendMessageMock.mockClear();11311132 await selectImportFile(container);1133 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1134 await flushView();11351136 findButton(container, "Replace vault with 1").click();1137 await flushView();11381139 expect(sendMessageMock).not.toHaveBeenCalledWith(1140 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1141 );1142 expect(container.textContent).toContain("Replace forever? This cannot be undone.");11431144 findButton(container, "Yes, replace forever").click();1145 await flushView();11461147 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1148 expect(sendMessageMock).toHaveBeenCalledWith({1149 type: "BATCH_ADD_ENTRIES",1150 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1151 });1152 expect(onImport).toHaveBeenCalled();1153 });11541155 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1156 parseImportSourceMock.mockResolvedValue(sampleImportResult());1157 sendMessageMock.mockImplementation(async (msg: unknown) => {1158 const type = (msg as { type?: string }).type;1159 if (type === "CREATE_VAULT") return { ok: true };1160 if (type === "BATCH_ADD_ENTRIES") {1161 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1162 }1163 return { ok: true };1164 });1165 const onImport = vi.fn(async () => undefined);1166 const container = mount(1167 <ImportView1168 mode="onboarding"1169 replacesExistingVault={true}1170 onBack={vi.fn()}1171 onImport={onImport}1172 />1173 );11741175 await selectImportFile(container);11761177 const createButton = findButton(container, "Create vault + import 1");1178 expect(createButton.disabled).toBe(true);1179 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1180 expect(container.textContent).toContain("Use at least 8 characters");11811182 const masterInput = container.querySelector(1183 'input[placeholder="new master password"]'1184 ) as HTMLInputElement;1185 const confirmInput = container.querySelector(1186 'input[placeholder="confirm master password"]'1187 ) as HTMLInputElement;1188 const revealButton = container.querySelector(1189 'button[aria-label="Show passwords"]'1190 ) as HTMLButtonElement;1191 expect(revealButton).toBeTruthy();1192 revealButton.click();1193 await flushView();1194 expect(masterInput.type).toBe("text");1195 expect(confirmInput.type).toBe("text");11961197 inputText(masterInput, "fresh-master-password");1198 inputText(confirmInput, "fresh-master-password");1199 await flushView();12001201 expect(createButton.disabled).toBe(false);1202 createButton.click();1203 await flushView();12041205 expect(sendMessageMock).toHaveBeenCalledWith({1206 type: "CREATE_VAULT",1207 payload: { password: "fresh-master-password" },1208 });1209 expect(sendMessageMock).toHaveBeenCalledWith({1210 type: "BATCH_ADD_ENTRIES",1211 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1212 });1213 expect(onImport).toHaveBeenCalled();1214 });12151216 it("Import encrypted backups reparse with the backup password before import", async () => {1217 parseImportSourceMock1218 .mockResolvedValueOnce(1219 sampleImportResult({1220 format: "peach-json",1221 label: "Peach encrypted backup",1222 entries: [],1223 totalItems: 0,1224 requiresPassword: true,1225 })1226 )1227 .mockResolvedValueOnce(1228 sampleImportResult({1229 format: "peach-json",1230 label: "Peach encrypted backup",1231 })1232 );1233 sendMessageMock.mockImplementation(async (msg: unknown) => {1234 const type = (msg as { type?: string }).type;1235 if (type === "GET_ENTRIES") return { ok: true, data: [] };1236 if (type === "BATCH_ADD_ENTRIES") {1237 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1238 }1239 return { ok: true };1240 });1241 const onImport = vi.fn(async () => undefined);1242 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1243 await flushView();1244 sendMessageMock.mockClear();12451246 await selectImportFile(container, '{"type":"peach-export"}');1247 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1248 inputText(password, "backup-passphrase");1249 pressEnter(password);1250 await flushView();1251 await flushView();12521253 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1254 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12551256 findButton(container, "Import 1").click();1257 await flushView();12581259 expect(sendMessageMock).toHaveBeenCalledWith({1260 type: "BATCH_ADD_ENTRIES",1261 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1262 });1263 expect(onImport).toHaveBeenCalled();1264 });1265});