Skip to content

Commit ae9f1f2

Browse files
committed
test(ui-react): add tests for secure vault feature
Cover vault crypto utilities, localStorage backend, SSH key utils, vault Zustand store, KeyFileInput hook and component, and secure-vault page components.
1 parent 48ad1dd commit ae9f1f2

File tree

10 files changed

+3311
-0
lines changed

10 files changed

+3311
-0
lines changed
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi } from "vitest";
3+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import KeyFileInput from "../KeyFileInput";
6+
7+
const noop = () => {};
8+
const alwaysValid = () => true;
9+
10+
interface Props {
11+
value?: string;
12+
onChange?: (v: string) => void;
13+
validate?: (t: string) => boolean;
14+
disabled?: boolean;
15+
error?: string | null;
16+
label?: string;
17+
id?: string;
18+
hint?: string;
19+
disabledHint?: string;
20+
loadedLabel?: string;
21+
emptyLabel?: string;
22+
onFileName?: (name: string) => void;
23+
}
24+
25+
function renderComponent(props: Props = {}) {
26+
const {
27+
value = "",
28+
onChange = noop,
29+
validate = alwaysValid,
30+
label = "Public Key",
31+
...rest
32+
} = props;
33+
return render(
34+
<KeyFileInput
35+
label={label}
36+
value={value}
37+
onChange={onChange}
38+
validate={validate}
39+
{...rest}
40+
/>,
41+
);
42+
}
43+
44+
function mockFileReader(content: string) {
45+
const original = globalThis.FileReader;
46+
47+
class MockFileReader extends EventTarget {
48+
result: string | null = null;
49+
onload: (() => void) | null = null;
50+
51+
readAsText() {
52+
Promise.resolve().then(() => {
53+
this.result = content;
54+
if (this.onload) this.onload();
55+
});
56+
}
57+
}
58+
59+
// @ts-expect-error - partial mock
60+
globalThis.FileReader = MockFileReader;
61+
return () => {
62+
globalThis.FileReader = original;
63+
};
64+
}
65+
66+
describe("KeyFileInput", () => {
67+
describe("label", () => {
68+
it("renders the label text", () => {
69+
renderComponent({ label: "Private Key" });
70+
expect(screen.getByText("Private Key")).toBeInTheDocument();
71+
});
72+
});
73+
74+
describe("mode toggle", () => {
75+
it("shows File and Text buttons when not disabled", () => {
76+
renderComponent();
77+
expect(screen.getByRole("button", { name: "File" })).toBeInTheDocument();
78+
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
79+
});
80+
81+
it("hides mode toggle buttons when disabled", () => {
82+
renderComponent({ disabled: true });
83+
expect(screen.queryByRole("button", { name: "File" })).not.toBeInTheDocument();
84+
expect(screen.queryByRole("button", { name: "Text" })).not.toBeInTheDocument();
85+
});
86+
87+
it("shows the drop zone by default (file mode)", () => {
88+
renderComponent();
89+
// Drop zone is identifiable by the empty-state label
90+
expect(
91+
screen.getByText("Drop key file, paste, or browse"),
92+
).toBeInTheDocument();
93+
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
94+
});
95+
96+
it("switches to textarea when Text button is clicked", async () => {
97+
renderComponent();
98+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
99+
expect(screen.getByRole("textbox")).toBeInTheDocument();
100+
});
101+
102+
it("switches back to drop zone when File button is clicked after Text", async () => {
103+
renderComponent();
104+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
105+
await userEvent.click(screen.getByRole("button", { name: "File" }));
106+
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
107+
expect(
108+
screen.getByText("Drop key file, paste, or browse"),
109+
).toBeInTheDocument();
110+
});
111+
});
112+
113+
describe("drop zone — empty state", () => {
114+
it("renders the empty label", () => {
115+
renderComponent();
116+
expect(
117+
screen.getByText("Drop key file, paste, or browse"),
118+
).toBeInTheDocument();
119+
});
120+
121+
it("renders a custom emptyLabel", () => {
122+
renderComponent({ emptyLabel: "Upload your public key" });
123+
expect(screen.getByText("Upload your public key")).toBeInTheDocument();
124+
});
125+
});
126+
127+
describe("drop zone — loaded state", () => {
128+
it("renders the loaded label when a value is present", () => {
129+
renderComponent({ value: "ssh-rsa AAAA" });
130+
expect(screen.getByText("Key loaded")).toBeInTheDocument();
131+
});
132+
133+
it("renders a custom loadedLabel", () => {
134+
renderComponent({ value: "ssh-rsa AAAA", loadedLabel: "Key ready" });
135+
expect(screen.getByText("Key ready")).toBeInTheDocument();
136+
});
137+
138+
it("renders a Clear button when key is loaded", () => {
139+
renderComponent({ value: "ssh-rsa AAAA" });
140+
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
141+
});
142+
143+
it("calls onChange('') when Clear is clicked", async () => {
144+
const onChange = vi.fn();
145+
renderComponent({ value: "ssh-rsa AAAA", onChange });
146+
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
147+
expect(onChange).toHaveBeenCalledWith("");
148+
});
149+
});
150+
151+
describe("drop zone — dragging state", () => {
152+
it("sets dragging visual when dragOver fires on the drop zone", () => {
153+
const { container } = renderComponent();
154+
// Find the drop zone div by a stable child element
155+
const dropZone = container.querySelector(
156+
"[ondragover], .border-dashed",
157+
) as HTMLElement | null;
158+
if (!dropZone) throw new Error("drop zone not found");
159+
160+
fireEvent.dragOver(dropZone, { preventDefault: () => {} });
161+
// After dragOver the border class shifts to primary color
162+
expect(dropZone.className).toMatch(/border-primary/);
163+
});
164+
165+
it("removes dragging visual on dragLeave", () => {
166+
const { container } = renderComponent();
167+
const dropZone = container.querySelector(".border-dashed") as HTMLElement;
168+
fireEvent.dragOver(dropZone);
169+
fireEvent.dragLeave(dropZone);
170+
expect(dropZone.className).not.toMatch(/bg-primary\/5/);
171+
});
172+
});
173+
174+
describe("drop zone — error state", () => {
175+
it("renders the error message", () => {
176+
renderComponent({ error: "Invalid key format" });
177+
expect(screen.getByText("Invalid key format")).toBeInTheDocument();
178+
});
179+
180+
it("does not render an error message when error is null", () => {
181+
renderComponent({ error: null });
182+
expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument();
183+
});
184+
});
185+
186+
describe("drag and drop", () => {
187+
it("calls onChange with file content after a valid drop", async () => {
188+
const restore = mockFileReader("ssh-rsa AAAAB3NzaC1");
189+
const onChange = vi.fn();
190+
const { container } = renderComponent({ onChange });
191+
const dropZone = container.querySelector(".border-dashed") as HTMLElement;
192+
193+
const file = new File(["ssh-rsa AAAAB3NzaC1"], "id_rsa.pub", {
194+
type: "text/plain",
195+
});
196+
const dataTransfer = { files: [file] };
197+
fireEvent.drop(dropZone, { dataTransfer });
198+
199+
await waitFor(() => expect(onChange).toHaveBeenCalledWith("ssh-rsa AAAAB3NzaC1"));
200+
restore();
201+
});
202+
203+
it("does not call onChange for files over 512 KB", async () => {
204+
const onChange = vi.fn();
205+
const { container } = renderComponent({ onChange });
206+
const dropZone = container.querySelector(".border-dashed") as HTMLElement;
207+
208+
const bigContent = new Uint8Array(513 * 1024);
209+
const bigFile = new File([bigContent], "big.pem", { type: "text/plain" });
210+
fireEvent.drop(dropZone, { dataTransfer: { files: [bigFile] } });
211+
212+
// Give any async processing time to run
213+
await new Promise((r) => setTimeout(r, 20));
214+
expect(onChange).not.toHaveBeenCalled();
215+
});
216+
});
217+
218+
describe("file input via browse", () => {
219+
it("clicking the drop zone triggers the hidden file input", async () => {
220+
const { container } = renderComponent();
221+
const fileInput = container.querySelector(
222+
'input[type="file"]',
223+
) as HTMLInputElement;
224+
const clickSpy = vi.spyOn(fileInput, "click");
225+
const dropZone = container.querySelector(".border-dashed") as HTMLElement;
226+
await userEvent.click(dropZone);
227+
expect(clickSpy).toHaveBeenCalled();
228+
});
229+
230+
it("calls onChange after selecting a file through the file input", async () => {
231+
const restore = mockFileReader("key text");
232+
const onChange = vi.fn();
233+
const { container } = renderComponent({ onChange });
234+
const fileInput = container.querySelector(
235+
'input[type="file"]',
236+
) as HTMLInputElement;
237+
238+
const file = new File(["key text"], "k.pem", { type: "text/plain" });
239+
fireEvent.change(fileInput, { target: { files: [file] } });
240+
241+
await waitFor(() => expect(onChange).toHaveBeenCalledWith("key text"));
242+
restore();
243+
});
244+
});
245+
246+
describe("disabled state", () => {
247+
it("renders a textarea (not the drop zone) when disabled", () => {
248+
renderComponent({ disabled: true });
249+
expect(screen.getByRole("textbox")).toBeInTheDocument();
250+
expect(screen.queryByText("Drop key file, paste, or browse")).not.toBeInTheDocument();
251+
});
252+
253+
it("textarea is disabled", () => {
254+
renderComponent({ disabled: true });
255+
expect(screen.getByRole("textbox")).toBeDisabled();
256+
});
257+
258+
it("renders disabledHint when disabled", () => {
259+
renderComponent({ disabled: true, disabledHint: "Cannot edit now" });
260+
expect(screen.getByText("Cannot edit now")).toBeInTheDocument();
261+
});
262+
263+
it("does not render hint when disabled", () => {
264+
renderComponent({ disabled: true, hint: "Upload a key" });
265+
expect(screen.queryByText("Upload a key")).not.toBeInTheDocument();
266+
});
267+
});
268+
269+
describe("hint", () => {
270+
it("renders the hint when not disabled", () => {
271+
renderComponent({ hint: "Paste or drag your key file" });
272+
expect(screen.getByText("Paste or drag your key file")).toBeInTheDocument();
273+
});
274+
275+
it("does not render disabledHint when not disabled", () => {
276+
renderComponent({ disabledHint: "Read only" });
277+
expect(screen.queryByText("Read only")).not.toBeInTheDocument();
278+
});
279+
});
280+
281+
describe("accessibility", () => {
282+
it("associates the label with the textarea via id in text mode", async () => {
283+
renderComponent({ id: "pub-key" });
284+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
285+
expect(screen.getByRole("textbox")).toHaveAttribute("id", "pub-key");
286+
expect(screen.getByLabelText("Public Key")).toBeInTheDocument();
287+
});
288+
289+
it("marks the textarea aria-invalid when error is provided (text mode)", async () => {
290+
renderComponent({ error: "Bad key", id: "pub-key" });
291+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
292+
const textarea = screen.getByRole("textbox");
293+
expect(textarea).toHaveAttribute("aria-invalid", "true");
294+
});
295+
296+
it("links the error paragraph via aria-describedby when id is provided (text mode)", async () => {
297+
renderComponent({ error: "Bad key", id: "pub-key" });
298+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
299+
const textarea = screen.getByRole("textbox");
300+
expect(textarea).toHaveAttribute("aria-describedby", "pub-key-error");
301+
});
302+
});
303+
304+
describe("text mode textarea", () => {
305+
it("reflects the current value", async () => {
306+
renderComponent({ value: "ssh-rsa ABC" });
307+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
308+
expect(screen.getByRole("textbox")).toHaveValue("ssh-rsa ABC");
309+
});
310+
311+
it("calls onChange when the user types", async () => {
312+
const onChange = vi.fn();
313+
renderComponent({ onChange });
314+
await userEvent.click(screen.getByRole("button", { name: "Text" }));
315+
await userEvent.type(screen.getByRole("textbox"), "a");
316+
expect(onChange).toHaveBeenCalled();
317+
});
318+
});
319+
});

0 commit comments

Comments
 (0)