From 6d92251fc184274f0802beedce138b64b2c976b7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Feb 2026 15:40:05 -0600 Subject: [PATCH] fix(SEC-WEB-27+28): Robust email validation + role cast validation 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 --- .../workspace/InviteMember.test.tsx | 109 ++++++++++++++ .../src/components/workspace/InviteMember.tsx | 5 +- .../components/workspace/MemberList.test.tsx | 109 ++++++++++++++ .../src/components/workspace/MemberList.tsx | 3 +- .../components/workspace/validation.test.ts | 134 ++++++++++++++++++ .../src/components/workspace/validation.ts | 96 +++++++++++++ 6 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/workspace/InviteMember.test.tsx create mode 100644 apps/web/src/components/workspace/MemberList.test.tsx create mode 100644 apps/web/src/components/workspace/validation.test.ts create mode 100644 apps/web/src/components/workspace/validation.ts diff --git a/apps/web/src/components/workspace/InviteMember.test.tsx b/apps/web/src/components/workspace/InviteMember.test.tsx new file mode 100644 index 0000000..05b737f --- /dev/null +++ b/apps/web/src/components/workspace/InviteMember.test.tsx @@ -0,0 +1,109 @@ +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
element in InviteMember"); + } + return form; +} + +describe("InviteMember", (): void => { + const mockOnInvite = vi.fn<(email: string, role: WorkspaceMemberRole) => Promise>(); + + beforeEach((): void => { + mockOnInvite.mockReset(); + mockOnInvite.mockResolvedValue(undefined); + vi.spyOn(window, "alert").mockImplementation((): undefined => undefined); + }); + + it("should render the invite form", (): void => { + render(); + 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 => { + render(); + + 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 => { + render(); + + 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 => { + render(); + + 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 => { + const user = userEvent.setup(); + render(); + + 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 => { + const user = userEvent.setup(); + render(); + + 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 => { + mockOnInvite.mockRejectedValueOnce(new Error("Invite failed")); + const user = userEvent.setup(); + render(); + + 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 => { + const user = userEvent.setup(); + render(); + + 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(""); + }); +}); diff --git a/apps/web/src/components/workspace/InviteMember.tsx b/apps/web/src/components/workspace/InviteMember.tsx index bd271b0..a49cf56 100644 --- a/apps/web/src/components/workspace/InviteMember.tsx +++ b/apps/web/src/components/workspace/InviteMember.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { WorkspaceMemberRole } from "@mosaic/shared"; +import { isValidEmail, toWorkspaceMemberRole } from "./validation"; interface InviteMemberProps { onInvite: (email: string, role: WorkspaceMemberRole) => Promise; @@ -22,7 +23,7 @@ export function InviteMember({ onInvite }: InviteMemberProps): React.JSX.Element return; } - if (!email.includes("@")) { + if (!isValidEmail(email.trim())) { setError("Please enter a valid email address"); return; } @@ -72,7 +73,7 @@ export function InviteMember({ onInvite }: InviteMemberProps): React.JSX.Element id="role" value={role} onChange={(e) => { - setRole(e.target.value as WorkspaceMemberRole); + setRole(toWorkspaceMemberRole(e.target.value)); }} disabled={isInviting} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" diff --git a/apps/web/src/components/workspace/MemberList.test.tsx b/apps/web/src/components/workspace/MemberList.test.tsx new file mode 100644 index 0000000..cb2fe8b --- /dev/null +++ b/apps/web/src/components/workspace/MemberList.test.tsx @@ -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 & { 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>(); + const mockOnRemove = vi.fn<(userId: string) => Promise>(); + + 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(); + 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(); + 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(); + expect(screen.getByText("(you)")).toBeInTheDocument(); + }); + + it("should call onRoleChange with validated role when admin changes a member role", async (): Promise => { + const user = userEvent.setup(); + const members = [ + makeMember({ userId: "user-1" }), + makeMember({ userId: "user-2", role: WorkspaceMemberRole.MEMBER }), + ]; + render(); + + 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(); + 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(); + expect(screen.queryByLabelText("Remove member")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/workspace/MemberList.tsx b/apps/web/src/components/workspace/MemberList.tsx index 199b111..19fcb68 100644 --- a/apps/web/src/components/workspace/MemberList.tsx +++ b/apps/web/src/components/workspace/MemberList.tsx @@ -2,6 +2,7 @@ import type { User, WorkspaceMember } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared"; +import { toWorkspaceMemberRole } from "./validation"; export interface WorkspaceMemberWithUser extends WorkspaceMember { user: User; @@ -88,7 +89,7 @@ export function MemberList({