import { describe, expect, it, vi, test, beforeEach, afterEach } from "vitest"; import { render, screen, within, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { createRoutesStub } from "react-router"; import { selectOrganization } from "test-utils"; import { organizationService } from "#/api/organization-service/organization-service.api"; import ManageOrganizationMembers from "#/routes/manage-organization-members"; import SettingsScreen, { clientLoader as settingsClientLoader, } from "#/routes/settings"; import { ORGS_AND_MEMBERS, resetOrgMockData, resetOrgsAndMembersMockData, } from "#/mocks/org-handlers"; import OptionService from "#/api/option-service/option-service.api"; function ManageOrganizationMembersWithPortalRoot() { return (
); } const RouteStub = createRoutesStub([ { // @ts-expect-error - ignoreing error for test stub loader: settingsClientLoader, Component: SettingsScreen, path: "/settings", HydrateFallback: () =>
Loading...
, children: [ { Component: ManageOrganizationMembersWithPortalRoot, path: "/settings/org-members", }, { Component: () =>
, path: "/settings/member", }, ], }, ]); let queryClient: QueryClient; describe("Manage Organization Members Route", () => { const getMeSpy = vi.spyOn(organizationService, "getMe"); beforeEach(() => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return APP_MODE for these tests getConfigSpy.mockResolvedValue({ APP_MODE: "saas", }); queryClient = new QueryClient(); // Set default mock for user (admin role has invite permission) getMeSpy.mockResolvedValue({ org_id: "1", user_id: "1", email: "test@example.com", role: "admin", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }); }); afterEach(() => { vi.restoreAllMocks(); // Reset organization mock data to ensure clean state between tests resetOrgMockData(); // Reset ORGS_AND_MEMBERS to initial state resetOrgsAndMembersMockData(); // Clear queryClient cache to ensure fresh data for next test queryClient.clear(); }); const renderManageOrganizationMembers = () => render(, { wrapper: ({ children }) => ( {children} ), }); // Helper function to find a member by email const findMemberByEmail = async (email: string) => { const memberListItems = await screen.findAllByTestId("member-item"); const member = memberListItems.find((item) => within(item).queryByText(email), ); if (!member) { throw new Error(`Could not find member with email: ${email}`); } return member; }; // Helper function to open role dropdown for a member const openRoleDropdown = async ( memberElement: HTMLElement, roleText: string, ) => { // Find the role text that's clickable (has cursor-pointer class or is the main role display) // Use a more specific query to avoid matching dropdown options const roleElement = within(memberElement).getByText( new RegExp(`^${roleText}$`, "i"), ); await userEvent.click(roleElement); return within(memberElement).getByTestId( "organization-member-role-context-menu", ); }; // Helper function to change member role const changeMemberRole = async ( memberElement: HTMLElement, currentRole: string, newRole: string, ) => { const dropdown = await openRoleDropdown(memberElement, currentRole); const roleOption = within(dropdown).getByText(new RegExp(newRole, "i")); await userEvent.click(roleOption); }; // Helper function to verify dropdown is not visible const expectDropdownNotVisible = (memberElement: HTMLElement) => { expect( within(memberElement).queryByTestId( "organization-member-role-context-menu", ), ).not.toBeInTheDocument(); }; // Helper function to setup test with user and organization const setupTestWithUserAndOrg = async ( userData: { org_id: string; user_id: string; email: string; role: "owner" | "admin" | "member"; llm_api_key: string; max_iterations: number; llm_model: string; llm_api_key_for_byor: string | null; llm_base_url: string; status: "active" | "invited" | "inactive"; }, orgIndex: number, ) => { getMeSpy.mockResolvedValue(userData); renderManageOrganizationMembers(); await screen.findByTestId("manage-organization-members-settings"); await selectOrganization({ orgIndex }); }; // Helper function to create updateMember spy const createUpdateMemberRoleSpy = () => vi.spyOn(organizationService, "updateMember"); // Helper function to verify role change is not permitted const verifyRoleChangeNotPermitted = async ( userData: { org_id: string; user_id: string; email: string; role: "owner" | "admin" | "member"; llm_api_key: string; max_iterations: number; llm_model: string; llm_api_key_for_byor: string | null; llm_base_url: string; status: "active" | "invited" | "inactive"; }, orgIndex: number, targetMemberIndex: number, expectedRoleText: string, ) => { await setupTestWithUserAndOrg(userData, orgIndex); const memberListItems = await screen.findAllByTestId("member-item"); const targetMember = memberListItems[targetMemberIndex]; const roleText = within(targetMember).getByText( new RegExp(`^${expectedRoleText}$`, "i"), ); expect(roleText).toBeInTheDocument(); await userEvent.click(roleText); // Verify that the dropdown does not open expectDropdownNotVisible(targetMember); }; // Helper function to setup invite test (render and select organization) const setupInviteTest = async (orgIndex: number = 0) => { renderManageOrganizationMembers(); await selectOrganization({ orgIndex }); }; // Helper function to setup test with organization (waits for settings screen) const setupTestWithOrg = async (orgIndex: number = 0) => { renderManageOrganizationMembers(); await screen.findByTestId("manage-organization-members-settings"); await selectOrganization({ orgIndex }); }; // Helper function to find invite button const findInviteButton = async () => await screen.findByRole("button", { name: /ORG\$INVITE_ORGANIZATION_MEMBER/i, }); // Helper function to verify all three role options are present in dropdown const expectAllRoleOptionsPresent = (dropdown: HTMLElement) => { expect(within(dropdown).getByText(/owner/i)).toBeInTheDocument(); expect(within(dropdown).getByText(/admin/i)).toBeInTheDocument(); expect(within(dropdown).getByText(/member/i)).toBeInTheDocument(); }; // Helper function to close dropdown by clicking outside const closeDropdown = async () => { await userEvent.click(document.body); }; // Helper function to verify owner option is not present in dropdown const expectOwnerOptionNotPresent = (dropdown: HTMLElement) => { expect(within(dropdown).queryByText(/owner/i)).not.toBeInTheDocument(); }; it("should render", async () => { renderManageOrganizationMembers(); await screen.findByTestId("manage-organization-members-settings"); }); it("should navigate away from the page if not saas", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return APP_MODE for these tests getConfigSpy.mockResolvedValue({ APP_MODE: "oss", }); renderManageOrganizationMembers(); expect( screen.queryByTestId("manage-organization-members-settings"), ).not.toBeInTheDocument(); }); it("should allow the user to select an organization", async () => { const getOrganizationMembersSpy = vi.spyOn( organizationService, "getOrganizationMembers", ); renderManageOrganizationMembers(); await screen.findByTestId("manage-organization-members-settings"); expect(getOrganizationMembersSpy).not.toHaveBeenCalled(); await selectOrganization({ orgIndex: 0 }); expect(getOrganizationMembersSpy).toHaveBeenCalledExactlyOnceWith({ orgId: "1", }); }); it("should render the list of organization members", async () => { await setupTestWithOrg(0); const members = ORGS_AND_MEMBERS["1"]; const memberListItems = await screen.findAllByTestId("member-item"); expect(memberListItems).toHaveLength(members.length); members.forEach((member) => { expect(screen.getByText(member.email)).toBeInTheDocument(); expect(screen.getByText(member.role)).toBeInTheDocument(); }); }); test("an admin should be able to change the role of a organization member", async () => { await setupTestWithUserAndOrg( { org_id: "1", user_id: "1", email: "test@example.com", role: "admin", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 0, ); const updateMemberRoleSpy = createUpdateMemberRoleSpy(); const memberListItems = await screen.findAllByTestId("member-item"); const userRoleMember = memberListItems[2]; // third member is "user" let userCombobox = within(userRoleMember).getByText(/^Member$/i); expect(userCombobox).toBeInTheDocument(); // Change role from user to admin await changeMemberRole(userRoleMember, "member", "admin"); expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ userId: "3", // assuming the third member is the one being updated orgId: "1", role: "admin", }); expectDropdownNotVisible(userRoleMember); // Verify the role has been updated in the UI userCombobox = within(userRoleMember).getByText(/^Admin$/i); expect(userCombobox).toBeInTheDocument(); // Revert the role back to user await changeMemberRole(userRoleMember, "admin", "member"); expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { userId: "3", orgId: "1", role: "member", }); // Verify the role has been reverted in the UI userCombobox = within(userRoleMember).getByText(/^Member$/i); expect(userCombobox).toBeInTheDocument(); }); it("should not allow an admin to change the owner's role", async () => { await verifyRoleChangeNotPermitted( { org_id: "3", user_id: "1", email: "test@example.com", role: "admin", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 2, // user is admin in org 3 0, // first member is "owner" "Owner", ); }); it("should not allow an admin to change another admin's role", async () => { await verifyRoleChangeNotPermitted( { org_id: "3", user_id: "1", email: "test@example.com", role: "admin", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 2, // user is admin in org 3 1, // second member is "admin" "Admin", ); }); it("should not allow a user to change their own role", async () => { // Mock the /me endpoint to return a user ID that matches one of the members await verifyRoleChangeNotPermitted( { org_id: "1", user_id: "1", // Same as first member from org 1 email: "alice@acme.org", role: "owner", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 0, 0, // First member (user_id: "1") "Owner", ); }); it("should show a remove option in the role dropdown and remove the user from the list", async () => { const removeMemberSpy = vi.spyOn(organizationService, "removeMember"); await setupTestWithOrg(0); // Get initial member count const memberListItems = await screen.findAllByTestId("member-item"); const initialMemberCount = memberListItems.length; const userRoleMember = memberListItems[2]; // third member is "user" const userEmail = within(userRoleMember).getByText("charlie@acme.org"); expect(userEmail).toBeInTheDocument(); const userCombobox = within(userRoleMember).getByText(/^Member$/i); await userEvent.click(userCombobox); const dropdown = within(userRoleMember).getByTestId( "organization-member-role-context-menu", ); // Check that remove option exists const removeOption = within(dropdown).getByTestId("remove-option"); expect(removeOption).toBeInTheDocument(); await userEvent.click(removeOption); expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({ orgId: "1", userId: "3", }); // Verify the user is no longer in the list await waitFor(() => { const updatedMemberListItems = screen.getAllByTestId("member-item"); expect(updatedMemberListItems).toHaveLength(initialMemberCount - 1); }); // Verify the specific user email is no longer present expect(screen.queryByText("charlie@acme.org")).not.toBeInTheDocument(); }); it.todo( "should not allow a user to change another user's role if they are the same role", ); describe("Inviting Organization Members", () => { it("should render an invite organization member button", async () => { await setupInviteTest(); const inviteButton = await findInviteButton(); expect(inviteButton).toBeInTheDocument(); }); it("should render a modal when the invite button is clicked", async () => { await setupInviteTest(); expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); const inviteButton = await findInviteButton(); await userEvent.click(inviteButton); const portalRoot = screen.getByTestId("portal-root"); expect( within(portalRoot).getByTestId("invite-modal"), ).toBeInTheDocument(); }); it("should close the modal when the close button is clicked", async () => { await setupInviteTest(); const inviteButton = await findInviteButton(); await userEvent.click(inviteButton); const modal = screen.getByTestId("invite-modal"); const closeButton = within(modal).getByText("BUTTON$CANCEL"); await userEvent.click(closeButton); expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); }); it("should render a list item in an invited state when a the user is is invited", async () => { const getOrganizationMembersSpy = vi.spyOn( organizationService, "getOrganizationMembers", ); getOrganizationMembersSpy.mockResolvedValue([ { org_id: "1", user_id: "4", email: "tom@acme.org", role: "member", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "invited", }, ]); await setupInviteTest(); const members = await screen.findAllByTestId("member-item"); expect(members).toHaveLength(1); const invitedMember = members[0]; expect(invitedMember).toBeInTheDocument(); // should have an "invited" badge const invitedBadge = within(invitedMember).getByText(/invited/i); expect(invitedBadge).toBeInTheDocument(); // should not have a role combobox await userEvent.click(within(invitedMember).getByText(/^Member$/i)); expect( within(invitedMember).queryByTestId( "organization-member-role-context-menu", ), ).not.toBeInTheDocument(); }); }); describe("Role-based invite permission behavior", () => { it.each([ { role: "owner" as const, roleName: "Owner" }, { role: "admin" as const, roleName: "Admin" }, ])( "should show invite button when user has canInviteUsers permission ($roleName role)", async ({ role }) => { getMeSpy.mockResolvedValue({ org_id: "1", user_id: "1", email: "test@example.com", role, llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }); await setupTestWithOrg(0); const inviteButton = await findInviteButton(); expect(inviteButton).toBeInTheDocument(); expect(inviteButton).not.toBeDisabled(); }, ); it("should not show invite button when user lacks canInviteUsers permission (User role)", async () => { const userData = { org_id: "1", user_id: "1", email: "test@example.com", role: "member" as const, llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active" as const, }; // Set mock and remove cached query before rendering getMeSpy.mockResolvedValue(userData); // Remove any cached "me" queries so fresh data is fetched queryClient.removeQueries({ queryKey: ["organizations"] }); await setupTestWithOrg(0); // Directly set the query data to force component re-render with user role // This ensures the component uses the user role data instead of cached admin data queryClient.setQueryData(["organizations", "1", "me"], userData); // Wait for the component to update with the new query data await waitFor( () => { const inviteButton = screen.queryByRole("button", { name: /ORG\$INVITE_ORGANIZATION_MEMBER/i, }); expect(inviteButton).not.toBeInTheDocument(); }, { timeout: 3000 }, ); }); }); describe("Role-based role change permission behavior", () => { it("should not allow an owner to change another owner's role", async () => { await verifyRoleChangeNotPermitted( { org_id: "1", user_id: "1", // First member is owner in org 1 email: "alice@acme.org", role: "owner", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 0, 0, // First member is owner "owner", ); }); it("Owner should see all three role options (owner, admin, user) in dropdown regardless of target member's role", async () => { await setupTestWithUserAndOrg( { org_id: "1", user_id: "1", // First member is owner in org 1 email: "alice@acme.org", role: "owner", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 0, ); const memberListItems = await screen.findAllByTestId("member-item"); // Test with admin member const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") const adminDropdown = await openRoleDropdown(adminMember, "admin"); // Verify all three role options are present for admin member expectAllRoleOptionsPresent(adminDropdown); // Close dropdown by clicking outside await closeDropdown(); // Test with user member const userMember = await findMemberByEmail("charlie@acme.org"); const userDropdown = await openRoleDropdown(userMember, "member"); // Verify all three role options are present for user member expectAllRoleOptionsPresent(userDropdown); }); it("Admin should not see owner option in role dropdown for any member", async () => { await setupTestWithUserAndOrg( { org_id: "3", user_id: "7", // Ray is admin in org 3 email: "ray@all-hands.dev", role: "admin", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 2, // org 3 ); const memberListItems = await screen.findAllByTestId("member-item"); // Check user member dropdown const userMember = memberListItems[2]; // user member const userDropdown = await openRoleDropdown(userMember, "member"); expectOwnerOptionNotPresent(userDropdown); await closeDropdown(); // Check another user member dropdown if exists if (memberListItems.length > 3) { const anotherUserMember = memberListItems[3]; // another user member const anotherUserDropdown = await openRoleDropdown( anotherUserMember, "member", ); expectOwnerOptionNotPresent(anotherUserDropdown); } }); it("Owner should be able to change any member's role to owner", async () => { await setupTestWithUserAndOrg( { org_id: "1", user_id: "1", // First member is owner in org 1 email: "alice@acme.org", role: "owner", llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active", }, 0, ); const updateMemberRoleSpy = createUpdateMemberRoleSpy(); const memberListItems = await screen.findAllByTestId("member-item"); // Test changing admin to owner const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") await changeMemberRole(adminMember, "admin", "owner"); expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(1, { userId: "2", orgId: "1", role: "owner", }); // Test changing user to owner const userMember = await findMemberByEmail("charlie@acme.org"); await changeMemberRole(userMember, "member", "owner"); expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { userId: "3", orgId: "1", role: "owner", }); }); it.each([ { description: "Owner should be able to change admin's role to admin (no change)", userData: { org_id: "1", user_id: "1", // First member is owner in org 1 email: "alice@acme.org", role: "owner" as const, llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active" as const, }, orgIndex: 0, memberEmail: "bob@acme.org", currentRole: "admin", newRole: "admin", expectedApiCall: { userId: "2", orgId: "1", role: "admin" as const, }, }, { description: "Admin should be able to change user's role to user (no change)", userData: { org_id: "3", user_id: "7", // Ray is admin in org 3 email: "ray@all-hands.dev", role: "admin" as const, llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active" as const, }, orgIndex: 2, // org 3 memberEmail: "stephan@all-hands.dev", currentRole: "member", newRole: "member", expectedApiCall: { userId: "9", orgId: "3", role: "member" as const, }, }, { description: "Admin should be able to change member's role to admin", userData: { org_id: "3", user_id: "7", // Ray is admin in org 3 email: "ray@all-hands.dev", role: "admin" as const, llm_api_key: "**********", max_iterations: 20, llm_model: "gpt-4", llm_api_key_for_byor: null, llm_base_url: "https://api.openai.com", status: "active" as const, }, orgIndex: 2, // org 3 memberEmail: "stephan@all-hands.dev", currentRole: "member", newRole: "admin", expectedApiCall: { userId: "9", orgId: "3", role: "admin" as const, }, }, ])( "$description", async ({ userData, orgIndex, memberEmail, currentRole, newRole, expectedApiCall, }) => { await setupTestWithUserAndOrg(userData, orgIndex); const updateMemberRoleSpy = createUpdateMemberRoleSpy(); const member = await findMemberByEmail(memberEmail); await changeMemberRole(member, currentRole, newRole); expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith( expectedApiCall, ); }, ); }); });