All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
SEC-WEB-27: Replace weak email.includes('@') check with RFC 5322-aligned
programmatic validation (isValidEmail). Uses character-level domain label
validation to avoid ReDoS vulnerabilities from complex regex patterns.
SEC-WEB-28: Replace unsafe 'as WorkspaceMemberRole' type casts with
runtime validation (toWorkspaceMemberRole) that checks against known enum
values and falls back to MEMBER for invalid inputs. Applied in both
InviteMember.tsx and MemberList.tsx.
Adds 43 tests covering validation logic, InviteMember component, and
MemberList component behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { WorkspaceMemberRole } from "@mosaic/shared";
|
|
import { InviteMember } from "./InviteMember";
|
|
|
|
/**
|
|
* Helper to get the invite form element from the rendered component.
|
|
* The form wraps the submit button, so we locate it via the button.
|
|
*/
|
|
function getForm(): HTMLFormElement {
|
|
const button = screen.getByRole("button", { name: /send invitation/i });
|
|
const form = button.closest("form");
|
|
if (!form) {
|
|
throw new Error("Could not locate <form> element in InviteMember");
|
|
}
|
|
return form;
|
|
}
|
|
|
|
describe("InviteMember", (): void => {
|
|
const mockOnInvite = vi.fn<(email: string, role: WorkspaceMemberRole) => Promise<void>>();
|
|
|
|
beforeEach((): void => {
|
|
mockOnInvite.mockReset();
|
|
mockOnInvite.mockResolvedValue(undefined);
|
|
vi.spyOn(window, "alert").mockImplementation((): undefined => undefined);
|
|
});
|
|
|
|
it("should render the invite form", (): void => {
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/role/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /send invitation/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("should show error for empty email", async (): Promise<void> => {
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
fireEvent.submit(getForm());
|
|
|
|
expect(await screen.findByText("Email is required")).toBeInTheDocument();
|
|
expect(mockOnInvite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should show error for invalid email without domain", async (): Promise<void> => {
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
fireEvent.change(emailInput, { target: { value: "notanemail" } });
|
|
fireEvent.submit(getForm());
|
|
|
|
expect(await screen.findByText("Please enter a valid email address")).toBeInTheDocument();
|
|
expect(mockOnInvite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should show error for email with only @ sign", async (): Promise<void> => {
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
fireEvent.change(emailInput, { target: { value: "user@" } });
|
|
fireEvent.submit(getForm());
|
|
|
|
expect(await screen.findByText("Please enter a valid email address")).toBeInTheDocument();
|
|
expect(mockOnInvite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should accept valid email and invoke onInvite", async (): Promise<void> => {
|
|
const user = userEvent.setup();
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
await user.type(screen.getByLabelText(/email address/i), "valid@example.com");
|
|
await user.click(screen.getByRole("button", { name: /send invitation/i }));
|
|
|
|
expect(mockOnInvite).toHaveBeenCalledWith("valid@example.com", WorkspaceMemberRole.MEMBER);
|
|
});
|
|
|
|
it("should allow selecting a different role", async (): Promise<void> => {
|
|
const user = userEvent.setup();
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
await user.type(screen.getByLabelText(/email address/i), "admin@example.com");
|
|
await user.selectOptions(screen.getByLabelText(/role/i), WorkspaceMemberRole.ADMIN);
|
|
await user.click(screen.getByRole("button", { name: /send invitation/i }));
|
|
|
|
expect(mockOnInvite).toHaveBeenCalledWith("admin@example.com", WorkspaceMemberRole.ADMIN);
|
|
});
|
|
|
|
it("should show error message when onInvite rejects", async (): Promise<void> => {
|
|
mockOnInvite.mockRejectedValueOnce(new Error("Invite failed"));
|
|
const user = userEvent.setup();
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
await user.type(screen.getByLabelText(/email address/i), "user@example.com");
|
|
await user.click(screen.getByRole("button", { name: /send invitation/i }));
|
|
|
|
expect(await screen.findByText("Invite failed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should reset form after successful invite", async (): Promise<void> => {
|
|
const user = userEvent.setup();
|
|
render(<InviteMember onInvite={mockOnInvite} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
await user.type(emailInput, "user@example.com");
|
|
await user.click(screen.getByRole("button", { name: /send invitation/i }));
|
|
|
|
expect(emailInput).toHaveValue("");
|
|
});
|
|
});
|