Skip to content

Commit ff5cfac

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 6cc37c0 commit ff5cfac

File tree

10 files changed

+3338
-0
lines changed

10 files changed

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

0 commit comments

Comments
 (0)