fix(SEC-WEB-27+28): Robust email validation + role cast validation
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>
This commit is contained in:
Jason Woltje
2026-02-06 15:40:05 -06:00
parent 65b078c85e
commit 6d92251fc1
6 changed files with 453 additions and 3 deletions

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { MemberList } from "./MemberList";
import type { WorkspaceMemberWithUser } from "./MemberList";
const makeMember = (
overrides: Partial<WorkspaceMemberWithUser> & { userId: string }
): WorkspaceMemberWithUser => ({
workspaceId: overrides.workspaceId ?? "ws-1",
userId: overrides.userId,
role: overrides.role ?? WorkspaceMemberRole.MEMBER,
joinedAt: overrides.joinedAt ?? new Date("2025-01-01"),
user: overrides.user ?? {
id: overrides.userId,
name: `User ${overrides.userId}`,
email: `${overrides.userId}@example.com`,
emailVerified: true,
image: null,
authProviderId: `auth-${overrides.userId}`,
preferences: {},
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
},
});
describe("MemberList", (): void => {
const mockOnRoleChange = vi.fn<(userId: string, newRole: WorkspaceMemberRole) => Promise<void>>();
const mockOnRemove = vi.fn<(userId: string) => Promise<void>>();
const defaultProps = {
currentUserId: "user-1",
currentUserRole: WorkspaceMemberRole.ADMIN,
workspaceOwnerId: "owner-1",
onRoleChange: mockOnRoleChange,
onRemove: mockOnRemove,
};
beforeEach((): void => {
mockOnRoleChange.mockReset();
mockOnRoleChange.mockResolvedValue(undefined);
mockOnRemove.mockReset();
mockOnRemove.mockResolvedValue(undefined);
});
it("should render member list with correct count", (): void => {
const members = [makeMember({ userId: "user-1" }), makeMember({ userId: "user-2" })];
render(<MemberList {...defaultProps} members={members} />);
expect(screen.getByText("Members (2)")).toBeInTheDocument();
});
it("should display member name and email", (): void => {
const members = [
makeMember({
userId: "user-2",
user: {
id: "user-2",
name: "Jane Doe",
email: "jane@example.com",
emailVerified: true,
image: null,
authProviderId: "auth-2",
preferences: {},
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
},
}),
];
render(<MemberList {...defaultProps} members={members} />);
expect(screen.getByText("Jane Doe")).toBeInTheDocument();
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
});
it("should show (you) indicator for current user", (): void => {
const members = [makeMember({ userId: "user-1" })];
render(<MemberList {...defaultProps} members={members} />);
expect(screen.getByText("(you)")).toBeInTheDocument();
});
it("should call onRoleChange with validated role when admin changes a member role", async (): Promise<void> => {
const user = userEvent.setup();
const members = [
makeMember({ userId: "user-1" }),
makeMember({ userId: "user-2", role: WorkspaceMemberRole.MEMBER }),
];
render(<MemberList {...defaultProps} members={members} />);
const roleSelect = screen.getByDisplayValue("Member");
await user.selectOptions(roleSelect, WorkspaceMemberRole.GUEST);
expect(mockOnRoleChange).toHaveBeenCalledWith("user-2", WorkspaceMemberRole.GUEST);
});
it("should not show role select for the workspace owner", (): void => {
const members = [
makeMember({ userId: "owner-1", role: WorkspaceMemberRole.OWNER }),
makeMember({ userId: "user-1", role: WorkspaceMemberRole.ADMIN }),
];
render(<MemberList {...defaultProps} members={members} />);
expect(screen.getByText("OWNER")).toBeInTheDocument();
});
it("should not show remove button for the workspace owner", (): void => {
const members = [makeMember({ userId: "owner-1", role: WorkspaceMemberRole.OWNER })];
render(<MemberList {...defaultProps} members={members} />);
expect(screen.queryByLabelText("Remove member")).not.toBeInTheDocument();
});
});