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 triggerPipPulseMock = vi.fn();18const exportEntriesToPeachJsonMock = vi.fn();19const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();20const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2122vi.mock("../App", () => ({23 sendMessage: (msg: unknown) => sendMessageMock(msg),24 triggerRing: () => triggerRingMock(),25 triggerPipPulse: () => triggerPipPulseMock(),26}));2728vi.mock("../../shared/backup", () => ({29 exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),30}));3132vi.mock("../../shared/import", () => ({33 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),34}));3536vi.mock("../../shared/favicon", () => ({37 faviconUrl: () => null,38}));3940vi.mock("../../crypto/totp", () => ({41 generateTOTP: () => ({ code: "123 456", remaining: 24 }),42}));4344vi.mock("../components/GeneratorDrawer", () => ({45 GeneratorDrawer: () => null,46}));4748vi.mock("./CJKImport", () => ({49 CJKImportView: () => null,50}));5152vi.mock("./CJKExport", () => ({53 CJKExportView: () => null,54}));5556vi.mock("zxcvbn", () => ({57 default: (value: string) => zxcvbnMock(value),58}));5960describe("popup contract tests", () => {61 const disposers: Array<() => void> = [];6263 beforeEach(() => {64 document.body.innerHTML = "";65 sendMessageMock.mockReset();66 triggerRingMock.mockReset();67 triggerPipPulseMock.mockReset();68 exportEntriesToPeachJsonMock.mockReset();69 parseImportSourceMock.mockReset();70 zxcvbnMock.mockReset();71 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));72 parseImportSourceMock.mockResolvedValue({73 format: "unknown",74 label: "Unknown format",75 entries: [],76 totalItems: 0,77 skippedItems: 0,78 requiresPassword: false,79 });8081 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {82 return window.setTimeout(() => cb(0), 0) as unknown as number;83 });84 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));85 vi.stubGlobal("URL", {86 createObjectURL: vi.fn(() => "blob:peach-test"),87 revokeObjectURL: vi.fn(),88 });89 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9091 (globalThis as { chrome?: typeof chrome }).chrome = {92 runtime: {93 getManifest: () => ({ version: "1.4.42" }),94 onMessage: { addListener: vi.fn() },95 },96 } as unknown as typeof chrome;97 });9899 afterEach(() => {100 while (disposers.length > 0) {101 disposers.pop()?.();102 }103 vi.unstubAllGlobals();104 });105106 function mount(view: JSX.Element) {107 const mounted = renderView(view);108 disposers.push(mounted.dispose);109 return mounted.container;110 }111112 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {113 return {114 id: "entry-1",115 type: "login",116 title: "GitHub",117 username: "june",118 password: "secret123",119 url: "https://github.com",120 totp: "JBSWY3DPEHPK3PXP",121 canary: {122 webhookUrl: "https://example.com/hook",123 },124 createdAt: 1,125 modifiedAt: 1,126 ...overrides,127 };128 }129130 function sampleImportResult(131 overrides: Partial<ImportParseResult> = {}132 ): ImportParseResult {133 return {134 format: "csv",135 label: "CSV import",136 entries: [137 {138 type: "login",139 title: "Imported site",140 username: "imported-user",141 password: "imported-pass",142 url: "https://imported.test",143 },144 ],145 totalItems: 1,146 skippedItems: 0,147 requiresPassword: false,148 ...overrides,149 };150 }151152 async function selectImportFile(153 container: HTMLElement,154 contents = '{"export":"test"}'155 ) {156 const input = container.querySelector('input[type="file"]') as HTMLInputElement;157 const file = new File([contents], "import.json", { type: "application/json" });158 Object.defineProperty(input, "files", {159 configurable: true,160 value: [file],161 });162 input.dispatchEvent(new Event("change", { bubbles: true }));163 await flushView();164 await flushView();165 }166167 function findButton(container: HTMLElement, text: string): HTMLButtonElement {168 return Array.from(container.querySelectorAll("button")).find(169 (candidate) => candidate.textContent?.trim() === text170 ) as HTMLButtonElement;171 }172173 it("Lock sends UNLOCK with the entered passphrase", async () => {174 sendMessageMock.mockResolvedValue({ ok: true });175 const onUnlock = vi.fn(async () => undefined);176 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178 const password = container.querySelector("#unlock-password") as HTMLInputElement;179 inputText(password, "correct horse battery staple");180 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181 await flushView();182183 expect(sendMessageMock).toHaveBeenCalledWith({184 type: "UNLOCK",185 payload: { password: "correct horse battery staple" },186 });187 expect(onUnlock).toHaveBeenCalled();188 });189190 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193 const password = container.querySelector("#master-password") as HTMLInputElement;194 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196 inputText(password, "short");197 inputText(confirm, "short");198 await flushView();199 expect(createButton.disabled).toBe(true);200201 zxcvbnMock.mockImplementation(() => ({ score: 1 }));202 inputText(password, "weakweakweak");203 inputText(confirm, "weakweakweak");204 await flushView();205 expect(createButton.disabled).toBe(true);206 });207208 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209 sendMessageMock.mockResolvedValue({ ok: true });210 zxcvbnMock.mockImplementation(() => ({ score: 3 }));211 const onUnlock = vi.fn(async () => undefined);212 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);213214 const password = container.querySelector("#master-password") as HTMLInputElement;215 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;216 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;217218 inputText(password, "correct horse battery staple");219 inputText(confirm, "correct horse battery staple");220 await flushView();221 expect(createButton.disabled).toBe(false);222223 pressEnter(confirm);224 await flushView();225226 expect(sendMessageMock).toHaveBeenCalledWith({227 type: "CREATE_VAULT",228 payload: { password: "correct horse battery staple" },229 });230 expect(onUnlock).toHaveBeenCalled();231 });232233 it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {234 const container = mount(<ExportView onBack={vi.fn()} />);235236 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;237 const button = Array.from(container.querySelectorAll("button")).find(238 (candidate) => candidate.textContent?.trim() === "Download backup file"239 ) as HTMLButtonElement;240241 inputText(password, "too-short");242 await flushView();243244 expect(button.disabled).toBe(true);245 expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();246 });247248 it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {249 sendMessageMock.mockResolvedValue({250 ok: true,251 data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],252 });253 exportEntriesToPeachJsonMock.mockResolvedValue({254 version: 2,255 type: "peach-export",256 });257 const container = mount(<ExportView onBack={vi.fn()} />);258259 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260 const button = Array.from(container.querySelectorAll("button")).find(261 (candidate) => candidate.textContent?.trim() === "Download backup file"262 ) as HTMLButtonElement;263264 inputText(password, "correct horse battery staple");265 await flushView();266 button.click();267 await flushView();268269 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });270 expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(271 [expect.objectContaining({ id: "entry-1" })],272 "correct horse battery staple"273 );274 });275276 it("Settings requires current, new, and confirm passwords before enabling save", async () => {277 sendMessageMock.mockResolvedValue({ ok: true, data: null });278 const container = mount(279 <SettingsView280 entriesCount={1}281 breachMonitoring={true}282 lockOnClose={true}283 autoLockMinutes={5}284 trashRetentionDays={30}285 theme="dark"286 onBreachMonitoringChange={vi.fn()}287 onLockOnCloseChange={vi.fn()}288 onAutoLockMinutesChange={vi.fn()}289 onTrashRetentionChange={vi.fn()}290 onThemeChange={vi.fn()}291 onBack={vi.fn()}292 onImport={vi.fn()}293 onExport={vi.fn()}294 onBreachCheckDone={vi.fn()}295 />296 );297 await flushView();298299 const changeButton = Array.from(container.querySelectorAll("button")).find(300 (candidate) => candidate.textContent?.trim() === "change"301 ) as HTMLButtonElement;302 changeButton.click();303 await flushView();304305 const saveButton = Array.from(container.querySelectorAll("button")).find(306 (candidate) => candidate.textContent?.trim() === "save new password"307 ) as HTMLButtonElement;308 expect(saveButton.disabled).toBe(true);309310 const inputs = container.querySelectorAll('input[type="password"]');311 inputText(inputs[0] as HTMLInputElement, "current-pass");312 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");313 await flushView();314 expect(saveButton.disabled).toBe(true);315316 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");317 await flushView();318 expect(saveButton.disabled).toBe(false);319 });320321 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {322 sendMessageMock.mockResolvedValue({ ok: true, data: null });323 const onAutoLockMinutesChange = vi.fn();324 const onThemeChange = vi.fn();325 const container = mount(326 <SettingsView327 entriesCount={1}328 breachMonitoring={true}329 lockOnClose={true}330 autoLockMinutes={5}331 trashRetentionDays={30}332 theme="dark"333 onBreachMonitoringChange={vi.fn()}334 onLockOnCloseChange={vi.fn()}335 onAutoLockMinutesChange={onAutoLockMinutesChange}336 onTrashRetentionChange={vi.fn()}337 onThemeChange={onThemeChange}338 onBack={vi.fn()}339 onImport={vi.fn()}340 onExport={vi.fn()}341 onBreachCheckDone={vi.fn()}342 />343 );344 await flushView();345 sendMessageMock.mockClear();346347 const selects = container.querySelectorAll("select");348 changeSelect(selects[0] as HTMLSelectElement, "15");349 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);350351 const themeButton = Array.from(container.querySelectorAll("button")).find(352 (candidate) => candidate.textContent?.trim() === "Switch to light"353 ) as HTMLButtonElement;354 themeButton.click();355 expect(onThemeChange).toHaveBeenCalledWith("light");356357 const changeButton = Array.from(container.querySelectorAll("button")).find(358 (candidate) => candidate.textContent?.trim() === "change"359 ) as HTMLButtonElement;360 changeButton.click();361 await flushView();362363 const inputs = container.querySelectorAll('input[type="password"]');364 inputText(inputs[0] as HTMLInputElement, "current-pass");365 inputText(inputs[1] as HTMLInputElement, "new-password-123");366 inputText(inputs[2] as HTMLInputElement, "new-password-123");367 await flushView();368369 const saveButton = Array.from(container.querySelectorAll("button")).find(370 (candidate) => candidate.textContent?.trim() === "save new password"371 ) as HTMLButtonElement;372 saveButton.click();373 await flushView();374375 expect(sendMessageMock).toHaveBeenCalledWith({376 type: "CHANGE_PASSWORD",377 payload: {378 currentPassword: "current-pass",379 newPassword: "new-password-123",380 },381 });382 });383384 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {385 sendMessageMock.mockResolvedValue({ ok: true });386 const container = mount(387 <EntryView388 entry={sampleEntry()}389 onDelete={vi.fn()}390 onFieldSaved={vi.fn(async () => undefined)}391 />392 );393394 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;395 passwordButton.click();396 await flushView();397398 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {399 type: "COPY",400 payload: { text: "secret123", label: "password" },401 });402 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {403 type: "FIRE_CANARY_PING",404 payload: {405 webhookUrl: "https://example.com/hook",406 entryTitle: "GitHub",407 trigger: "password_copied",408 },409 });410411 sendMessageMock.mockClear();412 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;413 usernameButton.click();414 await flushView();415416 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {417 type: "COPY",418 payload: { text: "june", label: "username" },419 });420 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {421 type: "FIRE_CANARY_PING",422 payload: {423 webhookUrl: "https://example.com/hook",424 entryTitle: "GitHub",425 trigger: "username_copied",426 },427 });428 });429430 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {431 sendMessageMock.mockResolvedValue({ ok: true });432 const onFieldSaved = vi.fn(async () => undefined);433 const container = mount(434 <EntryView435 entry={sampleEntry()}436 onDelete={vi.fn()}437 onFieldSaved={onFieldSaved}438 />439 );440441 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;442 passwordValue.click();443 await flushView();444445 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;446 inputText(passwordInput, "");447 await flushView();448449 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;450 saveButton.click();451 await flushView();452453 expect(sendMessageMock).not.toHaveBeenCalledWith(454 expect.objectContaining({ type: "UPDATE_ENTRY" })455 );456 expect(onFieldSaved).not.toHaveBeenCalled();457458 const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;459 totpButton.click();460 await flushView();461462 expect(sendMessageMock).toHaveBeenLastCalledWith({463 type: "COPY",464 payload: { text: "123456", label: "totp" },465 });466 });467468 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {469 parseImportSourceMock.mockResolvedValue(470 sampleImportResult({471 entries: [472 {473 type: "login",474 title: "Imported site",475 username: "imported-user",476 password: "imported-pass",477 url: "https://imported.test",478 },479 ],480 })481 );482 sendMessageMock.mockImplementation(async (msg: unknown) => {483 const type = (msg as { type?: string }).type;484 if (type === "GET_ENTRIES") {485 return {486 ok: true,487 data: [488 sampleEntry({489 id: "existing-1",490 title: "Imported site",491 username: "imported-user",492 url: "https://imported.test",493 }),494 ],495 };496 }497 if (type === "BATCH_ADD_ENTRIES") {498 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };499 }500 return { ok: true };501 });502 const onImport = vi.fn(async () => undefined);503 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);504 await flushView();505 sendMessageMock.mockClear();506507 await selectImportFile(container);508 findButton(container, "Import 1").click();509 await flushView();510511 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);512 expect(sendMessageMock).toHaveBeenCalledWith({513 type: "BATCH_ADD_ENTRIES",514 payload: {515 entries: [516 expect.objectContaining({517 title: "Imported site",518 username: "imported-user",519 password: "imported-pass",520 url: "https://imported.test",521 }),522 ],523 },524 });525 expect(onImport).toHaveBeenCalled();526 expect(container.textContent).toContain("1 entry appears to be duplicates");527 });528529 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {530 parseImportSourceMock.mockResolvedValue(sampleImportResult());531 sendMessageMock.mockImplementation(async (msg: unknown) => {532 const type = (msg as { type?: string }).type;533 if (type === "GET_ENTRIES") {534 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };535 }536 if (type === "CLEAR_VAULT_ENTRIES") {537 return { ok: true };538 }539 if (type === "BATCH_ADD_ENTRIES") {540 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };541 }542 return { ok: true };543 });544 const onImport = vi.fn(async () => undefined);545 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);546 await flushView();547 sendMessageMock.mockClear();548549 await selectImportFile(container);550 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();551 await flushView();552553 findButton(container, "Replace vault with 1").click();554 await flushView();555556 expect(sendMessageMock).not.toHaveBeenCalledWith(557 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })558 );559 expect(container.textContent).toContain("Replace forever? This cannot be undone.");560561 findButton(container, "Yes, replace forever").click();562 await flushView();563564 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });565 expect(sendMessageMock).toHaveBeenCalledWith({566 type: "BATCH_ADD_ENTRIES",567 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },568 });569 expect(onImport).toHaveBeenCalled();570 });571572 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {573 parseImportSourceMock.mockResolvedValue(sampleImportResult());574 sendMessageMock.mockImplementation(async (msg: unknown) => {575 const type = (msg as { type?: string }).type;576 if (type === "CREATE_VAULT") return { ok: true };577 if (type === "BATCH_ADD_ENTRIES") {578 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };579 }580 return { ok: true };581 });582 const onImport = vi.fn(async () => undefined);583 const container = mount(584 <ImportView585 mode="onboarding"586 replacesExistingVault={true}587 onBack={vi.fn()}588 onImport={onImport}589 />590 );591592 await selectImportFile(container);593594 const createButton = findButton(container, "Create vault + import 1");595 expect(createButton.disabled).toBe(true);596 expect(container.textContent).toContain("Restoring now replaces the locked local vault");597598 const inputs = container.querySelectorAll('input[type="password"]');599 inputText(inputs[0] as HTMLInputElement, "fresh-master-password");600 inputText(inputs[1] as HTMLInputElement, "fresh-master-password");601 await flushView();602603 expect(createButton.disabled).toBe(false);604 createButton.click();605 await flushView();606607 expect(sendMessageMock).toHaveBeenCalledWith({608 type: "CREATE_VAULT",609 payload: { password: "fresh-master-password" },610 });611 expect(sendMessageMock).toHaveBeenCalledWith({612 type: "BATCH_ADD_ENTRIES",613 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },614 });615 expect(onImport).toHaveBeenCalled();616 });617618 it("Import encrypted backups reparse with the backup password before import", async () => {619 parseImportSourceMock620 .mockResolvedValueOnce(621 sampleImportResult({622 format: "peach-json",623 label: "Peach encrypted backup",624 entries: [],625 totalItems: 0,626 requiresPassword: true,627 })628 )629 .mockResolvedValueOnce(630 sampleImportResult({631 format: "peach-json",632 label: "Peach encrypted backup",633 })634 );635 sendMessageMock.mockImplementation(async (msg: unknown) => {636 const type = (msg as { type?: string }).type;637 if (type === "GET_ENTRIES") return { ok: true, data: [] };638 if (type === "BATCH_ADD_ENTRIES") {639 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };640 }641 return { ok: true };642 });643 const onImport = vi.fn(async () => undefined);644 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);645 await flushView();646 sendMessageMock.mockClear();647648 await selectImportFile(container, '{"type":"peach-export"}');649 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;650 inputText(password, "backup-passphrase");651 pressEnter(password);652 await flushView();653 await flushView();654655 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);656 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");657658 findButton(container, "Import 1").click();659 await flushView();660661 expect(sendMessageMock).toHaveBeenCalledWith({662 type: "BATCH_ADD_ENTRIES",663 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },664 });665 expect(onImport).toHaveBeenCalled();666 });667});