import selectEvent from 'react-select-event'; import {UserEnrolledAuthenticatorFixture} from 'sentry-fixture/authenticators'; import {MemberFixture} from 'sentry-fixture/member'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {OrgRoleListFixture} from 'sentry-fixture/roleList'; import {TeamFixture} from 'sentry-fixture/team'; import {UserFixture} from 'sentry-fixture/user'; import {initializeOrg} from 'sentry-test/initializeOrg'; import { cleanup, render, renderGlobalModal, screen, userEvent, within, } from 'sentry-test/reactTestingLibrary'; import {updateMember} from 'sentry/actionCreators/members'; import TeamStore from 'sentry/stores/teamStore'; import OrganizationMemberDetail from 'sentry/views/settings/organizationMembers/organizationMemberDetail'; jest.mock('sentry/actionCreators/members', () => ({ updateMember: jest.fn().mockReturnValue(new Promise(() => {})), })); describe('OrganizationMemberDetail', function () { const team = TeamFixture(); const idpTeam = TeamFixture({ id: '3', slug: 'idp-member-team', name: 'Idp Member Team', isMember: true, flags: { 'idp:provisioned': true, }, }); const managerTeam = TeamFixture({id: '5', orgRole: 'manager', slug: 'manager-team'}); const otherManagerTeam = TeamFixture({ id: '4', slug: 'org-role-team', name: 'Org Role Team', isMember: true, orgRole: 'manager', }); const teams = [ team, TeamFixture({ id: '2', slug: 'new-team', name: 'New Team', isMember: false, }), idpTeam, managerTeam, otherManagerTeam, ]; const teamAssignment = { teams: [team.slug], teamRoles: [ { teamSlug: team.slug, role: null, }, ], }; const member = MemberFixture({ roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), ...teamAssignment, }); const pendingMember = MemberFixture({ id: '2', roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), ...teamAssignment, invite_link: 'http://example.com/i/abc123', pending: true, }); const expiredMember = MemberFixture({ id: '3', roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), ...teamAssignment, invite_link: 'http://example.com/i/abc123', pending: true, expired: true, }); const idpTeamMember = MemberFixture({ id: '4', roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), teams: [idpTeam.slug], teamRoles: [ { teamSlug: idpTeam.slug, role: null, }, ], }); const managerTeamMember = MemberFixture({ id: '5', roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), teams: [otherManagerTeam.slug], teamRoles: [ { teamSlug: otherManagerTeam.slug, role: null, }, ], }); const managerMember = MemberFixture({ id: '6', roles: OrgRoleListFixture(), role: 'manager', }); beforeEach(() => { MockApiClient.clearMockResponses(); TeamStore.loadInitialData(teams); }); describe('Can Edit', function () { const organization = OrganizationFixture({teams, features: ['team-roles']}); beforeEach(function () { TeamStore.init(); TeamStore.loadInitialData(teams); jest.resetAllMocks(); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${member.id}/`, body: member, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${pendingMember.id}/`, body: pendingMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${expiredMember.id}/`, body: expiredMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${idpTeamMember.id}/`, body: idpTeamMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${managerTeamMember.id}/`, body: managerTeamMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${managerMember.id}/`, body: managerMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/teams/`, body: teams, }); }); it('changes org role to owner', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Should have 4 roles const radios = screen.getAllByRole('radio'); expect(radios).toHaveLength(4); // Click last radio await userEvent.click(radios.at(-1) as Element); expect(radios.at(-1)).toBeChecked(); // Save Member await userEvent.click(screen.getByRole('button', {name: 'Save Member'})); expect(updateMember).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ data: expect.objectContaining({ orgRole: 'owner', }), }) ); }); it('leaves a team', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Remove our one team await userEvent.click(screen.getByRole('button', {name: 'Remove'})); // Save Member await userEvent.click(screen.getByRole('button', {name: 'Save Member'})); expect(updateMember).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ data: expect.objectContaining({ teamRoles: [], }), }) ); }); it('cannot leave idp-provisioned team', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled(); }); it('cannot leave org role team if missing org:admin', function () { const regularOrg = OrganizationFixture({ teams, features: ['team-roles'], access: [], }); const {routerContext, routerProps} = initializeOrg({organization: regularOrg}); render( , { context: routerContext, organization: regularOrg, } ); expect(screen.getByText('Manager Team')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled(); }); it('cannot join org role team if missing org:admin', async function () { const regularOrg = OrganizationFixture({ teams, features: ['team-roles'], access: ['org:write'], }); const {routerContext, routerProps} = initializeOrg({organization: regularOrg}); render( , { context: routerContext, organization: regularOrg, } ); await userEvent.click(screen.getByText('Add Team')); await userEvent.hover(screen.getByText('#org-role-team')); expect( await screen.findByText( 'Membership to a team with an organization role is managed by org owners.' ) ).toBeInTheDocument(); }); it('joins a team and assign a team-role', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Should have one team enabled expect(screen.getByTestId('team-row-for-member')).toBeInTheDocument(); // Select new team to join // Open the dropdown await userEvent.click(screen.getByText('Add Team')); // Click the first item await userEvent.click(screen.getByText('#new-team')); // Assign as admin to new team const teamRoleSelect = screen.getAllByText('Contributor')[0]; await selectEvent.select(teamRoleSelect, ['Team Admin']); // Save Member await userEvent.click(screen.getByRole('button', {name: 'Save Member'})); expect(updateMember).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ data: expect.objectContaining({ teamRoles: [ {teamSlug: 'team-slug', role: null}, {teamSlug: 'new-team', role: 'admin'}, ], }), }) ); }); it('cannot join idp-provisioned team', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); await userEvent.click(screen.getByText('Add Team')); await userEvent.hover(screen.getByText('#idp-member-team')); expect( await screen.findByText( "Membership to this team is managed through your organization's identity provider." ) ).toBeInTheDocument(); }); }); describe('Cannot Edit', function () { const organization = OrganizationFixture({teams, access: ['org:read']}); beforeEach(function () { TeamStore.init(); TeamStore.loadInitialData(teams); jest.resetAllMocks(); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${member.id}/`, body: member, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${pendingMember.id}/`, body: pendingMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${expiredMember.id}/`, body: expiredMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/teams/`, body: teams, }); }); it('can not change roles, teams, or save', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Should have 4 roles const radios = screen.getAllByRole('radio'); expect(radios.at(0)).toHaveAttribute('readonly'); // Save Member expect(screen.getByRole('button', {name: 'Save Member'})).toBeDisabled(); }); }); describe('Display status', function () { const organization = OrganizationFixture({teams, access: ['org:read']}); beforeEach(function () { TeamStore.init(); TeamStore.loadInitialData(teams); jest.resetAllMocks(); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${member.id}/`, body: member, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${pendingMember.id}/`, body: pendingMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${expiredMember.id}/`, body: expiredMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/teams/`, body: teams, }); }); it('display pending status', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect(screen.getByTestId('member-status')).toHaveTextContent('Invitation Pending'); }); it('display expired status', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect(screen.getByTestId('member-status')).toHaveTextContent('Invitation Expired'); }); }); describe('Show resend button', function () { const organization = OrganizationFixture({teams, access: ['org:read']}); beforeEach(function () { TeamStore.init(); TeamStore.loadInitialData(teams); jest.resetAllMocks(); MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${member.id}/`, body: member, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${pendingMember.id}/`, body: pendingMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${expiredMember.id}/`, body: expiredMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/teams/`, body: teams, }); }); it('shows for pending', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect(screen.getByRole('button', {name: 'Resend Invite'})).toBeInTheDocument(); }); it('does not show for expired', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect( screen.queryByRole('button', {name: 'Resend Invite'}) ).not.toBeInTheDocument(); }); }); describe('Reset member 2FA', function () { const fields = { roles: OrgRoleListFixture(), dateCreated: new Date().toISOString(), ...teamAssignment, }; const noAccess = MemberFixture({ ...fields, id: '4', user: UserFixture({has2fa: false, authenticators: undefined}), }); const no2fa = MemberFixture({ ...fields, id: '5', user: UserFixture({has2fa: false, authenticators: [], canReset2fa: true}), }); const has2fa = MemberFixture({ ...fields, id: '6', user: UserFixture({ has2fa: true, authenticators: [ UserEnrolledAuthenticatorFixture({type: 'totp', id: 'totp'}), UserEnrolledAuthenticatorFixture({type: 'sms', id: 'sms'}), UserEnrolledAuthenticatorFixture({type: 'u2f', id: 'u2f'}), ], canReset2fa: true, }), }); const multipleOrgs = MemberFixture({ ...fields, id: '7', user: UserFixture({ has2fa: true, authenticators: [UserEnrolledAuthenticatorFixture({type: 'totp', id: 'totp'})], canReset2fa: false, }), }); const organization = OrganizationFixture({teams}); beforeEach(function () { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${pendingMember.id}/`, body: pendingMember, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${noAccess.id}/`, body: noAccess, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${no2fa.id}/`, body: no2fa, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${has2fa.id}/`, body: has2fa, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${multipleOrgs.id}/`, body: multipleOrgs, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/teams/`, body: teams, }); }); const button = () => screen.queryByRole('button', {name: 'Reset two-factor authentication'}); const tooltip = () => screen.queryByTestId('reset-2fa-tooltip'); const expectButtonEnabled = () => { expect(button()).toHaveTextContent('Reset two-factor authentication'); expect(button()).toBeEnabled(); expect(tooltip()).not.toBeInTheDocument(); }; const expectButtonDisabled = async title => { expect(button()).toHaveTextContent('Reset two-factor authentication'); expect(button()).toBeDisabled(); await userEvent.hover(button() as Element); expect(await screen.findByText(title)).toBeInTheDocument(); }; it('does not show for pending member', function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); expect(button()).not.toBeInTheDocument(); }); it('shows tooltip for joined member without permission to edit', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); await expectButtonDisabled('You do not have permission to perform this action'); }); it('shows tooltip for member without 2fa', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); await expectButtonDisabled('Not enrolled in two-factor authentication'); }); it('can reset member 2FA', async function () { const {routerContext, routerProps} = initializeOrg({organization}); const deleteMocks = (has2fa.user?.authenticators || []).map(auth => MockApiClient.addMockResponse({ url: `/users/${has2fa.user?.id}/authenticators/${auth.id}/`, method: 'DELETE', }) ); render( , { context: routerContext, organization, } ); renderGlobalModal(); expectButtonEnabled(); await userEvent.click(button() as Element); await userEvent.click(screen.getByRole('button', {name: 'Confirm'})); deleteMocks.forEach(deleteMock => { expect(deleteMock).toHaveBeenCalled(); }); }); it('shows tooltip for member in multiple orgs', async function () { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); await expectButtonDisabled( 'Cannot be reset since user is in more than one organization' ); }); it('shows tooltip for member in 2FA required org', async function () { organization.require2FA = true; const {routerContext, routerProps} = initializeOrg({organization}); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${has2fa.id}/`, body: has2fa, }); render( , { context: routerContext, organization, } ); await expectButtonDisabled( 'Cannot be reset since two-factor is required for this organization' ); }); }); describe('Org Roles affect Team Roles', () => { // Org Admin will be deprecated const admin = MemberFixture({ id: '4', role: 'admin', roleName: 'Admin', orgRole: 'admin', ...teamAssignment, }); const manager = MemberFixture({ id: '5', role: 'manager', roleName: 'Manager', orgRole: 'manager', ...teamAssignment, }); const owner = MemberFixture({ id: '6', role: 'owner', roleName: 'Owner', orgRole: 'owner', ...teamAssignment, }); const organization = OrganizationFixture({teams, features: ['team-roles']}); beforeEach(() => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${member.id}/`, body: member, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${admin.id}/`, body: admin, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${manager.id}/`, body: manager, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/${owner.id}/`, body: owner, }); }); it('does not overwrite team-roles for org members', async () => { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Role info box is hidden expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument(); // Dropdown has correct value set const teamRow = screen.getByTestId('team-row-for-member'); const teamRoleSelect = within(teamRow).getByText('Contributor'); // Dropdown options are not visible expect(screen.queryAllByText('...').length).toBe(0); // Dropdown can be opened selectEvent.openMenu(teamRoleSelect); expect(screen.queryAllByText('...').length).toBe(2); // Dropdown value can be changed await selectEvent.select(teamRoleSelect, ['Team Admin']); expect(teamRoleSelect).toHaveTextContent('Team Admin'); }); it('overwrite team-roles for org admin/manager/owner', () => { const {routerContext, routerProps} = initializeOrg({organization}); function testForOrgRole(testMember) { cleanup(); render( , { context: routerContext, organization, } ); // Role info box is showed expect(screen.queryByTestId('alert-role-overwrite')).toBeInTheDocument(); // Dropdown has correct value set const teamRow = screen.getByTestId('team-row-for-member'); const teamRoleSelect = within(teamRow).getByText('Team Admin'); // Dropdown options are not visible expect(screen.queryAllByText('...').length).toBe(0); // Dropdown cannot be opened selectEvent.openMenu(teamRoleSelect); expect(screen.queryAllByText('...').length).toBe(0); } for (const role of [admin, manager, owner]) { testForOrgRole(role); } }); it('overwrites when changing from member to manager', async () => { const {routerContext, routerProps} = initializeOrg({organization}); render( , { context: routerContext, organization, } ); // Role info box is hidden expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument(); // Dropdown has correct value set const teamRow = screen.getByTestId('team-row-for-member'); const teamRoleSelect = within(teamRow).getByText('Contributor'); // Change member to owner const orgRoleRadio = screen.getAllByRole('radio'); expect(orgRoleRadio).toHaveLength(4); await userEvent.click(orgRoleRadio.at(-1) as Element); expect(orgRoleRadio.at(-1)).toBeChecked(); // Role info box is shown expect(screen.queryByTestId('alert-role-overwrite')).toBeInTheDocument(); // Dropdown has correct value set within(teamRow).getByText('Team Admin'); // Dropdown options are not visible expect(screen.queryAllByText('...').length).toBe(0); // Dropdown cannot be opened selectEvent.openMenu(teamRoleSelect); expect(screen.queryAllByText('...').length).toBe(0); }); it('overwrites when member joins a manager team', async () => { const {routerContext, routerProps} = initializeOrg({}); render( , { context: routerContext, organization, } ); // Role info box is hidden expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument(); // Dropdown has correct value set const teamRow = screen.getByTestId('team-row-for-member'); const teamRoleSelect = within(teamRow).getByText('Contributor'); // Join manager team await userEvent.click(screen.getByText('Add Team')); // Click the first item await userEvent.click(screen.getByText('#manager-team')); // Role info box is shown expect(screen.queryByTestId('alert-role-overwrite')).toBeInTheDocument(); // Dropdowns have correct value set const teamRows = screen.getAllByTestId('team-row-for-member'); within(teamRows[0]).getByText('Team Admin'); within(teamRows[1]).getByText('Team Admin'); // Dropdown options are not visible expect(screen.queryAllByText('...').length).toBe(0); // Dropdown cannot be opened selectEvent.openMenu(teamRoleSelect); expect(screen.queryAllByText('...').length).toBe(0); }); }); });