diff --git a/app/(authenticated)/vault/_components/logins/import.test.tsx b/app/(authenticated)/vault/_components/logins/import.test.tsx new file mode 100644 index 00000000..12db87d2 --- /dev/null +++ b/app/(authenticated)/vault/_components/logins/import.test.tsx @@ -0,0 +1,157 @@ +import type * as React from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import { z } from "zod"; +import { + parseLoginVaultPayload, + vaultImportItemsSchema, +} from "@shared/vault/schema"; +import type { Input } from "@web/components/ui/input"; + +type StateDispatch = ReturnType[1]; + +interface Mocks { + change: ComponentProps["onChange"]; + dispatches: Mock[]; +} + +const mocks = vi.hoisted(() => ({ + change: undefined, + dispatches: [], +})); + +function DialogPart({ children }: { children: ReactNode }) { + return children; +} + +vi.mock("react", async (importOriginal) => { + const react = await importOriginal(); + return { + ...react, + useState: (initial: T) => { + const dispatch = vi.fn(); + mocks.dispatches.push(dispatch); + return [initial, dispatch]; + }, + }; +}); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn<() => void>() }), +})); +vi.mock("@web/trpc/client", () => ({ + api: { + vault: { import: { useMutation: () => ({ reset: vi.fn<() => void>() }) } }, + }, +})); +vi.mock("@web/components/ui/input", () => ({ + Input: ({ onChange }: ComponentProps) => { + mocks.change = onChange; + return ; + }, +})); +vi.mock("@web/components/ui/dialog", () => { + return { + DialogDescription: DialogPart, + DialogFooter: DialogPart, + DialogHeader: DialogPart, + DialogTitle: DialogPart, + }; +}); + +import { ChromeImportPanel } from "./import"; + +function choose(file?: File) { + const event = { currentTarget: { files: file ? [file] : [] } }; + mocks.change?.( + // SAFETY: the change handler reads only currentTarget.files, supplied here. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- minimal event fixture + event as typeof event & Parameters>[0] + ); +} + +function csv(account: string) { + return `name,url,username,password,note\nExample,https://example.com/,${account},sample-password,\n`; +} + +function pendingFile(name: string) { + const file = new File([], name, { type: "text/csv" }); + const read = Promise.withResolvers(); + vi.spyOn(file, "text").mockReturnValue(read.promise); + return { file, read }; +} + +function selectedIdentifier() { + const selection = z + .object({ items: vaultImportItemsSchema }) + .safeParse(mocks.dispatches[0]?.mock.lastCall?.[0]).data; + return selection + ? parseLoginVaultPayload(selection.items[0]?.secret ?? "")?.identifier.value + : undefined; +} + +describe("Chrome password file selection", () => { + beforeEach(() => { + mocks.dispatches = []; + mocks.change = undefined; + renderToStaticMarkup( void>()} />); + }); + + it("keeps the latest CSV when an older file finishes reading last", async () => { + const older = pendingFile("older.csv"); + const latest = pendingFile("latest.csv"); + choose(older.file); + choose(latest.file); + latest.read.resolve(csv("latest@example.com")); + await vi.waitFor(() => { + expect(selectedIdentifier()).toBe("latest@example.com"); + }); + older.read.resolve(csv("older@example.com")); + await older.read.promise; + await Promise.resolve(); + expect(selectedIdentifier()).toBe("latest@example.com"); + }); + + it("does not restore a file after the selection is cleared", async () => { + const older = pendingFile("older.csv"); + choose(older.file); + choose(); + older.read.resolve(csv("older@example.com")); + await older.read.promise; + await Promise.resolve(); + expect(selectedIdentifier()).toBeUndefined(); + }); + + it("ignores a stale read error after a newer CSV succeeds", async () => { + const older = pendingFile("older.csv"); + const latest = pendingFile("latest.csv"); + choose(older.file); + choose(latest.file); + latest.read.resolve(csv("latest@example.com")); + await vi.waitFor(() => { + expect(selectedIdentifier()).toBe("latest@example.com"); + }); + older.read.reject(new Error("The earlier file could not be read.")); + await older.read.promise.catch(() => undefined); + await Promise.resolve(); + expect(mocks.dispatches[2]?.mock.lastCall?.[0]).toBeUndefined(); + }); + + it("reads a single normal CSV and preserves its password", async () => { + const current = pendingFile("current.csv"); + choose(current.file); + current.read.resolve(csv("current@example.com")); + await vi.waitFor(() => { + expect(selectedIdentifier()).toBe("current@example.com"); + }); + const selection = z + .object({ items: vaultImportItemsSchema }) + .parse(mocks.dispatches[0]?.mock.lastCall?.[0]); + expect( + parseLoginVaultPayload(selection.items[0]?.secret ?? "")?.authentication + ).toEqual({ + password: "sample-password", + type: "password", + }); + }); +}); diff --git a/app/(authenticated)/vault/_components/logins/import.tsx b/app/(authenticated)/vault/_components/logins/import.tsx index d26ddda3..407349fa 100644 --- a/app/(authenticated)/vault/_components/logins/import.tsx +++ b/app/(authenticated)/vault/_components/logins/import.tsx @@ -6,7 +6,7 @@ import { ShieldCheckIcon, UploadIcon, } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Alert, AlertDescription, AlertTitle } from "@web/components/ui/alert"; import { Button } from "@web/components/ui/button"; @@ -37,8 +37,10 @@ export function ChromeImportPanel({ onDone }: { readonly onDone: () => void }) { const [error, setError] = useState(); const [importedCount, setImportedCount] = useState(); const [inputKey, setInputKey] = useState(0); + const fileSelection = useRef(0); const chooseFile = async (file?: File) => { + const selectionId = ++fileSelection.current; importPasswords.reset(); setError(undefined); setImportedCount(undefined); @@ -51,8 +53,11 @@ export function ChromeImportPanel({ onDone }: { readonly onDone: () => void }) { } try { - setSelection(parseChromePasswordsCsv(await file.text())); + const csv = await file.text(); + if (selectionId !== fileSelection.current) return; + setSelection(parseChromePasswordsCsv(csv)); } catch (parseError) { + if (selectionId !== fileSelection.current) return; setError( parseError instanceof Error ? parseError.message @@ -77,6 +82,7 @@ export function ChromeImportPanel({ onDone }: { readonly onDone: () => void }) { }; const reset = () => { + fileSelection.current += 1; importPasswords.reset(); setSelection(undefined); setFileName("");