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 { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { EntryView } from "./Entry";12import { ImportView } from "./Import";13import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1415const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();16const triggerRingMock = vi.fn();17const triggerTotpRingMock = vi.fn();18const triggerPipPulseMock = vi.fn();19const exportEntriesToPeachJsonMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24 sendMessage: (msg: unknown) => sendMessageMock(msg),25 triggerRing: () => triggerRingMock(),26 triggerTotpRing: () => triggerTotpRingMock(),27 triggerPipPulse: () => triggerPipPulseMock(),28}));2930vi.mock("../../shared/backup", () => ({31 exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32}));3334vi.mock("../../shared/import", () => ({35 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),36}));3738vi.mock("../../shared/favicon", () => ({39 faviconUrl: () => null,40}));4142vi.mock("../../crypto/totp", () => ({43 generateTOTP: () => ({ code: "123 456", remaining: 24 }),44}));4546vi.mock("../components/GeneratorDrawer", () => ({47 GeneratorDrawer: () => null,48}));4950vi.mock("./CJKImport", () => ({51 CJKImportView: () => null,52}));5354vi.mock("./CJKExport", () => ({55 CJKExportView: () => null,56}));5758vi.mock("zxcvbn", () => ({59 default: (value: string) => zxcvbnMock(value),60}));6162describe("popup contract tests", () => {63 const disposers: Array<() => void> = [];6465 beforeEach(() => {66 document.body.innerHTML = "";67 sendMessageMock.mockReset();68 triggerRingMock.mockReset();69 triggerTotpRingMock.mockReset();70 triggerPipPulseMock.mockReset();71 exportEntriesToPeachJsonMock.mockReset();72 parseImportSourceMock.mockReset();73 zxcvbnMock.mockReset();74 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));75 parseImportSourceMock.mockResolvedValue({76 format: "unknown",77 label: "Unknown format",78 entries: [],79 totalItems: 0,80 skippedItems: 0,81 requiresPassword: false,82 });8384 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {85 return window.setTimeout(() => cb(0), 0) as unknown as number;86 });87 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));88 vi.stubGlobal("URL", {89 createObjectURL: vi.fn(() => "blob:peach-test"),90 revokeObjectURL: vi.fn(),91 });92 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9394 (globalThis as { chrome?: typeof chrome }).chrome = {95 runtime: {96 getManifest: () => ({ version: "1.4.45" }),97 onMessage: { addListener: vi.fn() },98 },99 } as unknown as typeof chrome;100 });101102 afterEach(() => {103 while (disposers.length > 0) {104 disposers.pop()?.();105 }106 vi.unstubAllGlobals();107 });108109 function mount(view: JSX.Element) {110 const mounted = renderView(view);111 disposers.push(mounted.dispose);112 return mounted.container;113 }114115 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {116 return {117 id: "entry-1",118 type: "login",119 title: "GitHub",120 username: "june",121 password: "secret123",122 url: "https://github.com",123 totp: "JBSWY3DPEHPK3PXP",124 canary: {125 webhookUrl: "https://example.com/hook",126 },127 createdAt: 1,128 modifiedAt: 1,129 ...overrides,130 };131 }132133 function sampleImportResult(134 overrides: Partial<ImportParseResult> = {}135 ): ImportParseResult {136 return {137 format: "csv",138 label: "CSV import",139 entries: [140 {141 type: "login",142 title: "Imported site",143 username: "imported-user",144 password: "imported-pass",145 url: "https://imported.test",146 },147 ],148 totalItems: 1,149 skippedItems: 0,150 requiresPassword: false,151 ...overrides,152 };153 }154155 async function selectImportFile(156 container: HTMLElement,157 contents = '{"export":"test"}'158 ) {159 const input = container.querySelector('input[type="file"]') as HTMLInputElement;160 const file = new File([contents], "import.json", { type: "application/json" });161 Object.defineProperty(input, "files", {162 configurable: true,163 value: [file],164 });165 input.dispatchEvent(new Event("change", { bubbles: true }));166 await flushView();167 await flushView();168 }169170 function findButton(container: HTMLElement, text: string): HTMLButtonElement {171 return Array.from(container.querySelectorAll("button")).find(172 (candidate) => candidate.textContent?.trim() === text173 ) as HTMLButtonElement;174 }175176 it("Lock sends UNLOCK with the entered passphrase", async () => {177 sendMessageMock.mockResolvedValue({ ok: true });178 const onUnlock = vi.fn(async () => undefined);179 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);180181 const password = container.querySelector("#unlock-password") as HTMLInputElement;182 inputText(password, "correct horse battery staple");183 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();184 await flushView();185186 expect(sendMessageMock).toHaveBeenCalledWith({187 type: "UNLOCK",188 payload: { password: "correct horse battery staple" },189 });190 expect(onUnlock).toHaveBeenCalled();191 });192193 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {194 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);195 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;196 const password = container.querySelector("#master-password") as HTMLInputElement;197 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;198199 inputText(password, "short");200 inputText(confirm, "short");201 await flushView();202 expect(createButton.disabled).toBe(true);203204 zxcvbnMock.mockImplementation(() => ({ score: 1 }));205 inputText(password, "weakweakweak");206 inputText(confirm, "weakweakweak");207 await flushView();208 expect(createButton.disabled).toBe(true);209 });210211 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {212 sendMessageMock.mockResolvedValue({ ok: true });213 zxcvbnMock.mockImplementation(() => ({ score: 3 }));214 const onUnlock = vi.fn(async () => undefined);215 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);216217 const password = container.querySelector("#master-password") as HTMLInputElement;218 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;219 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;220221 inputText(password, "correct horse battery staple");222 inputText(confirm, "correct horse battery staple");223 await flushView();224 expect(createButton.disabled).toBe(false);225226 pressEnter(confirm);227 await flushView();228229 expect(sendMessageMock).toHaveBeenCalledWith({230 type: "CREATE_VAULT",231 payload: { password: "correct horse battery staple" },232 });233 expect(onUnlock).toHaveBeenCalled();234 });235236 it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {237 const container = mount(<ExportView onBack={vi.fn()} />);238239 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;240 const button = Array.from(container.querySelectorAll("button")).find(241 (candidate) => candidate.textContent?.trim() === "Download backup file"242 ) as HTMLButtonElement;243244 inputText(password, "too-short");245 await flushView();246247 expect(button.disabled).toBe(true);248 expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();249 });250251 it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {252 sendMessageMock.mockResolvedValue({253 ok: true,254 data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],255 });256 exportEntriesToPeachJsonMock.mockResolvedValue({257 version: 2,258 type: "peach-export",259 });260 const container = mount(<ExportView onBack={vi.fn()} />);261262 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;263 const button = Array.from(container.querySelectorAll("button")).find(264 (candidate) => candidate.textContent?.trim() === "Download backup file"265 ) as HTMLButtonElement;266267 inputText(password, "correct horse battery staple");268 await flushView();269 button.click();270 await flushView();271272 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });273 expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(274 [expect.objectContaining({ id: "entry-1" })],275 "correct horse battery staple"276 );277 });278279 it("Settings requires current, new, and confirm passwords before enabling save", async () => {280 sendMessageMock.mockResolvedValue({ ok: true, data: null });281 const container = mount(282 <SettingsView283 entriesCount={1}284 breachMonitoring={true}285 lockOnClose={true}286 autoLockMinutes={5}287 trashRetentionDays={30}288 theme="dark"289 onBreachMonitoringChange={vi.fn()}290 onLockOnCloseChange={vi.fn()}291 onAutoLockMinutesChange={vi.fn()}292 onTrashRetentionChange={vi.fn()}293 onThemeChange={vi.fn()}294 onBack={vi.fn()}295 onImport={vi.fn()}296 onExport={vi.fn()}297 onBreachCheckDone={vi.fn()}298 />299 );300 await flushView();301302 const changeButton = Array.from(container.querySelectorAll("button")).find(303 (candidate) => candidate.textContent?.trim() === "change"304 ) as HTMLButtonElement;305 changeButton.click();306 await flushView();307308 const saveButton = Array.from(container.querySelectorAll("button")).find(309 (candidate) => candidate.textContent?.trim() === "save new password"310 ) as HTMLButtonElement;311 expect(saveButton.disabled).toBe(true);312313 const inputs = container.querySelectorAll('input[type="password"]');314 inputText(inputs[0] as HTMLInputElement, "current-pass");315 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");316 await flushView();317 expect(saveButton.disabled).toBe(true);318319 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");320 await flushView();321 expect(saveButton.disabled).toBe(false);322 });323324 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {325 sendMessageMock.mockResolvedValue({ ok: true, data: null });326 const onAutoLockMinutesChange = vi.fn();327 const onThemeChange = vi.fn();328 const container = mount(329 <SettingsView330 entriesCount={1}331 breachMonitoring={true}332 lockOnClose={true}333 autoLockMinutes={5}334 trashRetentionDays={30}335 theme="dark"336 onBreachMonitoringChange={vi.fn()}337 onLockOnCloseChange={vi.fn()}338 onAutoLockMinutesChange={onAutoLockMinutesChange}339 onTrashRetentionChange={vi.fn()}340 onThemeChange={onThemeChange}341 onBack={vi.fn()}342 onImport={vi.fn()}343 onExport={vi.fn()}344 onBreachCheckDone={vi.fn()}345 />346 );347 await flushView();348 sendMessageMock.mockClear();349350 const selects = container.querySelectorAll("select");351 changeSelect(selects[0] as HTMLSelectElement, "15");352 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);353354 const themeButton = Array.from(container.querySelectorAll("button")).find(355 (candidate) => candidate.textContent?.trim() === "Switch to light"356 ) as HTMLButtonElement;357 themeButton.click();358 expect(onThemeChange).toHaveBeenCalledWith("light");359360 const changeButton = Array.from(container.querySelectorAll("button")).find(361 (candidate) => candidate.textContent?.trim() === "change"362 ) as HTMLButtonElement;363 changeButton.click();364 await flushView();365366 const inputs = container.querySelectorAll('input[type="password"]');367 inputText(inputs[0] as HTMLInputElement, "current-pass");368 inputText(inputs[1] as HTMLInputElement, "new-password-123");369 inputText(inputs[2] as HTMLInputElement, "new-password-123");370 await flushView();371372 const saveButton = Array.from(container.querySelectorAll("button")).find(373 (candidate) => candidate.textContent?.trim() === "save new password"374 ) as HTMLButtonElement;375 saveButton.click();376 await flushView();377378 expect(sendMessageMock).toHaveBeenCalledWith({379 type: "CHANGE_PASSWORD",380 payload: {381 currentPassword: "current-pass",382 newPassword: "new-password-123",383 },384 });385 });386387 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {388 sendMessageMock.mockResolvedValue({ ok: true });389 const container = mount(390 <EntryView391 entry={sampleEntry()}392 onDelete={vi.fn()}393 onFieldSaved={vi.fn(async () => undefined)}394 />395 );396397 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;398 passwordButton.click();399 await flushView();400401 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {402 type: "COPY",403 payload: { text: "secret123", label: "password" },404 });405 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {406 type: "FIRE_CANARY_PING",407 payload: {408 webhookUrl: "https://example.com/hook",409 entryTitle: "GitHub",410 trigger: "password_copied",411 },412 });413414 sendMessageMock.mockClear();415 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;416 usernameButton.click();417 await flushView();418419 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {420 type: "COPY",421 payload: { text: "june", label: "username" },422 });423 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {424 type: "FIRE_CANARY_PING",425 payload: {426 webhookUrl: "https://example.com/hook",427 entryTitle: "GitHub",428 trigger: "username_copied",429 },430 });431 });432433 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {434 sendMessageMock.mockResolvedValue({ ok: true });435 const onFieldSaved = vi.fn(async () => undefined);436 const container = mount(437 <EntryView438 entry={sampleEntry()}439 onDelete={vi.fn()}440 onFieldSaved={onFieldSaved}441 />442 );443444 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;445 passwordValue.click();446 await flushView();447448 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;449 inputText(passwordInput, "");450 await flushView();451452 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;453 saveButton.click();454 await flushView();455456 expect(sendMessageMock).not.toHaveBeenCalledWith(457 expect.objectContaining({ type: "UPDATE_ENTRY" })458 );459 expect(onFieldSaved).not.toHaveBeenCalled();460461 const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;462 totpButton.click();463 await flushView();464465 expect(sendMessageMock).toHaveBeenLastCalledWith({466 type: "COPY",467 payload: { text: "123456", label: "totp" },468 });469 });470471 it("Entry closes an unchanged inline editor when focus leaves it", async () => {472 sendMessageMock.mockResolvedValue({ ok: true });473 const container = mount(474 <EntryView475 entry={sampleEntry()}476 onDelete={vi.fn()}477 onFieldSaved={vi.fn(async () => undefined)}478 />479 );480481 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;482 usernameValue.click();483 await flushView();484485 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;486 usernameInput.blur();487 await flushView();488489 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();490 expect(sendMessageMock).not.toHaveBeenCalledWith(491 expect.objectContaining({ type: "UPDATE_ENTRY" })492 );493 });494495 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {496 parseImportSourceMock.mockResolvedValue(497 sampleImportResult({498 entries: [499 {500 type: "login",501 title: "Imported site",502 username: "imported-user",503 password: "imported-pass",504 url: "https://imported.test",505 },506 ],507 })508 );509 sendMessageMock.mockImplementation(async (msg: unknown) => {510 const type = (msg as { type?: string }).type;511 if (type === "GET_ENTRIES") {512 return {513 ok: true,514 data: [515 sampleEntry({516 id: "existing-1",517 title: "Imported site",518 username: "imported-user",519 url: "https://imported.test",520 }),521 ],522 };523 }524 if (type === "BATCH_ADD_ENTRIES") {525 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };526 }527 return { ok: true };528 });529 const onImport = vi.fn(async () => undefined);530 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);531 await flushView();532 sendMessageMock.mockClear();533534 await selectImportFile(container);535 findButton(container, "Import 1").click();536 await flushView();537538 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);539 expect(sendMessageMock).toHaveBeenCalledWith({540 type: "BATCH_ADD_ENTRIES",541 payload: {542 entries: [543 expect.objectContaining({544 title: "Imported site",545 username: "imported-user",546 password: "imported-pass",547 url: "https://imported.test",548 }),549 ],550 },551 });552 expect(onImport).toHaveBeenCalled();553 expect(container.textContent).toContain("1 entry appears to be duplicates");554 });555556 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {557 parseImportSourceMock.mockResolvedValue(sampleImportResult());558 sendMessageMock.mockImplementation(async (msg: unknown) => {559 const type = (msg as { type?: string }).type;560 if (type === "GET_ENTRIES") {561 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };562 }563 if (type === "CLEAR_VAULT_ENTRIES") {564 return { ok: true };565 }566 if (type === "BATCH_ADD_ENTRIES") {567 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };568 }569 return { ok: true };570 });571 const onImport = vi.fn(async () => undefined);572 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);573 await flushView();574 sendMessageMock.mockClear();575576 await selectImportFile(container);577 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();578 await flushView();579580 findButton(container, "Replace vault with 1").click();581 await flushView();582583 expect(sendMessageMock).not.toHaveBeenCalledWith(584 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })585 );586 expect(container.textContent).toContain("Replace forever? This cannot be undone.");587588 findButton(container, "Yes, replace forever").click();589 await flushView();590591 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });592 expect(sendMessageMock).toHaveBeenCalledWith({593 type: "BATCH_ADD_ENTRIES",594 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },595 });596 expect(onImport).toHaveBeenCalled();597 });598599 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {600 parseImportSourceMock.mockResolvedValue(sampleImportResult());601 sendMessageMock.mockImplementation(async (msg: unknown) => {602 const type = (msg as { type?: string }).type;603 if (type === "CREATE_VAULT") return { ok: true };604 if (type === "BATCH_ADD_ENTRIES") {605 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };606 }607 return { ok: true };608 });609 const onImport = vi.fn(async () => undefined);610 const container = mount(611 <ImportView612 mode="onboarding"613 replacesExistingVault={true}614 onBack={vi.fn()}615 onImport={onImport}616 />617 );618619 await selectImportFile(container);620621 const createButton = findButton(container, "Create vault + import 1");622 expect(createButton.disabled).toBe(true);623 expect(container.textContent).toContain("Restoring now replaces the locked local vault");624625 const inputs = container.querySelectorAll('input[type="password"]');626 inputText(inputs[0] as HTMLInputElement, "fresh-master-password");627 inputText(inputs[1] as HTMLInputElement, "fresh-master-password");628 await flushView();629630 expect(createButton.disabled).toBe(false);631 createButton.click();632 await flushView();633634 expect(sendMessageMock).toHaveBeenCalledWith({635 type: "CREATE_VAULT",636 payload: { password: "fresh-master-password" },637 });638 expect(sendMessageMock).toHaveBeenCalledWith({639 type: "BATCH_ADD_ENTRIES",640 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },641 });642 expect(onImport).toHaveBeenCalled();643 });644645 it("Import encrypted backups reparse with the backup password before import", async () => {646 parseImportSourceMock647 .mockResolvedValueOnce(648 sampleImportResult({649 format: "peach-json",650 label: "Peach encrypted backup",651 entries: [],652 totalItems: 0,653 requiresPassword: true,654 })655 )656 .mockResolvedValueOnce(657 sampleImportResult({658 format: "peach-json",659 label: "Peach encrypted backup",660 })661 );662 sendMessageMock.mockImplementation(async (msg: unknown) => {663 const type = (msg as { type?: string }).type;664 if (type === "GET_ENTRIES") return { ok: true, data: [] };665 if (type === "BATCH_ADD_ENTRIES") {666 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };667 }668 return { ok: true };669 });670 const onImport = vi.fn(async () => undefined);671 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);672 await flushView();673 sendMessageMock.mockClear();674675 await selectImportFile(container, '{"type":"peach-export"}');676 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;677 inputText(password, "backup-passphrase");678 pressEnter(password);679 await flushView();680 await flushView();681682 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);683 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");684685 findButton(container, "Import 1").click();686 await flushView();687688 expect(sendMessageMock).toHaveBeenCalledWith({689 type: "BATCH_ADD_ENTRIES",690 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },691 });692 expect(onImport).toHaveBeenCalled();693 });694});