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 selectEvent from 'sentry-test/selectEvent';
import {updateMember} from 'sentry/actionCreators/members';
import TeamStore from 'sentry/stores/teamStore';
import type {Member} from 'sentry/types/organization';
import OrganizationMemberDetail from './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 teams = [
team,
TeamFixture({
id: '2',
slug: 'new-team',
name: 'New Team',
isMember: false,
}),
idpTeam,
];
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 managerMember = MemberFixture({
id: '6',
roles: OrgRoleListFixture(),
role: 'manager',
});
beforeEach(() => {
MockApiClient.clearMockResponses();
TeamStore.loadInitialData(teams);
});
describe('Can Edit', function () {
const organization = OrganizationFixture({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/${managerMember.id}/`,
body: managerMember,
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/teams/`,
body: teams,
});
});
it('changes org role to owner', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Should have 5 roles
const radios = await screen.findAllByRole('radio');
expect(radios).toHaveLength(5);
// 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 {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Remove our one team
await userEvent.click(await screen.findByRole('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', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: idpTeamMember.id}},
});
render(, {
router,
organization,
});
expect(await screen.findByRole('button', {name: 'Remove'})).toBeDisabled();
});
it('joins a team and assign a team-role', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Should have one team enabled
expect(await screen.findByTestId('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 {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
await userEvent.click(await screen.findByText('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({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', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Should have 4 roles
const radios = await screen.findAllByRole('radio');
expect(radios.at(0)).toHaveAttribute('readonly');
// Save Member
expect(screen.getByRole('button', {name: 'Save Member'})).toBeDisabled();
});
});
describe('Display status', function () {
const organization = OrganizationFixture({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', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: pendingMember.id}},
});
render(, {
router,
organization,
});
expect(await screen.findByTestId('member-status')).toHaveTextContent(
'Invitation Pending'
);
});
it('display expired status', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: expiredMember.id}},
});
render(, {
router,
organization,
});
expect(await screen.findByTestId('member-status')).toHaveTextContent(
'Invitation Expired'
);
});
});
describe('Show resend button', function () {
const organization = OrganizationFixture({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', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: pendingMember.id}},
});
render(, {
router,
organization,
});
expect(
await screen.findByRole('button', {name: 'Resend Invite'})
).toBeInTheDocument();
});
it('does not show for expired', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: expiredMember.id}},
});
render(, {
router,
organization,
});
await screen.findAllByRole('radio');
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();
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 tooltip = () => screen.queryByTestId('reset-2fa-tooltip');
const expectButtonEnabled = async () => {
const button = await screen.findByRole('button', {
name: 'Reset two-factor authentication',
});
expect(button).toHaveTextContent('Reset two-factor authentication');
expect(button).toBeEnabled();
expect(tooltip()).not.toBeInTheDocument();
};
const expectButtonDisabled = async (title: string) => {
const button = await screen.findByRole('button', {
name: 'Reset two-factor authentication',
});
expect(button).toHaveTextContent('Reset two-factor authentication');
expect(button).toBeDisabled();
await userEvent.hover(button);
expect(await screen.findByText(title)).toBeInTheDocument();
};
it('does not show for pending member', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: pendingMember.id}},
});
render(, {
router,
organization,
});
expect(
await screen.findByRole('button', {name: 'Resend Invite'})
).toBeInTheDocument();
expect(
screen.queryByRole('button', {name: 'Reset two-factor authentication'})
).not.toBeInTheDocument();
});
it('shows tooltip for joined member without permission to edit', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: noAccess.id}},
});
render(, {
router,
organization,
});
await expectButtonDisabled('You do not have permission to perform this action');
});
it('shows tooltip for member without 2fa', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: no2fa.id}},
});
render(, {
router,
organization,
});
await expectButtonDisabled('Not enrolled in two-factor authentication');
});
it('can reset member 2FA', async function () {
const {router} = initializeOrg({
organization,
router: {params: {memberId: has2fa.id}},
});
const deleteMocks = (has2fa.user?.authenticators || []).map(auth =>
MockApiClient.addMockResponse({
url: `/users/${has2fa.user?.id}/authenticators/${auth.id}/`,
method: 'DELETE',
})
);
render(, {
router,
organization,
});
renderGlobalModal();
await expectButtonEnabled();
await userEvent.click(
screen.getByRole('button', {name: 'Reset two-factor authentication'})
);
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 {router} = initializeOrg({
organization,
router: {params: {memberId: multipleOrgs.id}},
});
render(, {
router,
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 {router} = initializeOrg({
organization,
router: {params: {memberId: has2fa.id}},
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/members/${has2fa.id}/`,
body: has2fa,
});
render(, {
router,
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({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 {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Role info box is hidden
expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument();
// Dropdown has correct value set
const teamRow = await screen.findByTestId('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
await selectEvent.openMenu(teamRoleSelect);
expect(screen.queryAllByText('...').length).toBe(2);
// Dropdown value can be changed
await userEvent.click(screen.getByLabelText('Team Admin'));
expect(teamRoleSelect).toHaveTextContent('Team Admin');
});
it('overwrite team-roles for org admin/manager/owner', async () => {
async function testForOrgRole(testMember: Member) {
const {router} = initializeOrg({
organization,
router: {params: {memberId: testMember.id}},
});
cleanup();
render(, {
router,
organization,
});
// Role info box is showed
expect(await screen.findByTestId('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
await selectEvent.openMenu(teamRoleSelect);
expect(screen.queryAllByText('...').length).toBe(0);
}
for (const role of [admin, manager, owner]) {
await testForOrgRole(role);
}
});
it('overwrites when changing from member to manager', async () => {
const {router} = initializeOrg({
organization,
router: {params: {memberId: member.id}},
});
render(, {
router,
organization,
});
// Dropdown has correct value set
const teamRow = await screen.findByTestId('team-row-for-member');
const teamRoleSelect = within(teamRow).getByText('Contributor');
// Role info box is hidden
expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument();
// Change member to owner
const orgRoleRadio = screen.getAllByRole('radio');
expect(orgRoleRadio).toHaveLength(5);
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
await selectEvent.openMenu(teamRoleSelect);
expect(screen.queryAllByText('...').length).toBe(0);
});
});
});