organizationMemberDetail.spec.jsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import selectEvent from 'react-select-event';
  2. import {
  3. cleanup,
  4. render,
  5. renderGlobalModal,
  6. screen,
  7. userEvent,
  8. within,
  9. } from 'sentry-test/reactTestingLibrary';
  10. import {updateMember} from 'sentry/actionCreators/members';
  11. import TeamStore from 'sentry/stores/teamStore';
  12. import OrganizationMemberDetail from 'sentry/views/settings/organizationMembers/organizationMemberDetail';
  13. jest.mock('sentry/actionCreators/members', () => ({
  14. updateMember: jest.fn().mockReturnValue(new Promise(() => {})),
  15. }));
  16. describe('OrganizationMemberDetail', function () {
  17. let organization;
  18. let routerContext;
  19. const team = TestStubs.Team();
  20. const idpTeam = TestStubs.Team({
  21. id: '4',
  22. slug: 'idp-member-team',
  23. name: 'Idp Member Team',
  24. isMember: true,
  25. flags: {
  26. 'idp:provisioned': true,
  27. },
  28. });
  29. const teams = [
  30. team,
  31. TestStubs.Team({
  32. id: '2',
  33. slug: 'new-team',
  34. name: 'New Team',
  35. isMember: false,
  36. }),
  37. TestStubs.Team({
  38. id: '3',
  39. slug: 'idp-team',
  40. name: 'Idp Team',
  41. isMember: false,
  42. flags: {
  43. 'idp:provisioned': true,
  44. },
  45. }),
  46. idpTeam,
  47. ];
  48. const teamAssignment = {
  49. teams: [team.slug],
  50. teamRoles: [
  51. {
  52. teamSlug: team.slug,
  53. role: null,
  54. },
  55. ],
  56. };
  57. const member = TestStubs.Member({
  58. roles: TestStubs.OrgRoleList(),
  59. dateCreated: new Date(),
  60. ...teamAssignment,
  61. });
  62. const pendingMember = TestStubs.Member({
  63. id: 2,
  64. roles: TestStubs.OrgRoleList(),
  65. dateCreated: new Date(),
  66. ...teamAssignment,
  67. invite_link: 'http://example.com/i/abc123',
  68. pending: true,
  69. });
  70. const expiredMember = TestStubs.Member({
  71. id: 3,
  72. roles: TestStubs.OrgRoleList(),
  73. dateCreated: new Date(),
  74. ...teamAssignment,
  75. invite_link: 'http://example.com/i/abc123',
  76. pending: true,
  77. expired: true,
  78. });
  79. const idpTeamMember = TestStubs.Member({
  80. id: 4,
  81. roles: TestStubs.OrgRoleList(),
  82. dateCreated: new Date(),
  83. teams: [idpTeam.slug],
  84. teamRoles: [
  85. {
  86. teamSlug: idpTeam.slug,
  87. role: null,
  88. },
  89. ],
  90. });
  91. beforeAll(() => {
  92. TeamStore.loadInitialData(teams);
  93. });
  94. describe('Can Edit', function () {
  95. beforeEach(function () {
  96. organization = TestStubs.Organization({teams, features: ['team-roles']});
  97. routerContext = TestStubs.routerContext([{organization}]);
  98. TeamStore.init();
  99. TeamStore.loadInitialData(teams);
  100. jest.resetAllMocks();
  101. MockApiClient.clearMockResponses();
  102. MockApiClient.addMockResponse({
  103. url: `/organizations/${organization.slug}/members/${member.id}/`,
  104. body: member,
  105. });
  106. MockApiClient.addMockResponse({
  107. url: `/organizations/${organization.slug}/members/${pendingMember.id}/`,
  108. body: pendingMember,
  109. });
  110. MockApiClient.addMockResponse({
  111. url: `/organizations/${organization.slug}/members/${expiredMember.id}/`,
  112. body: expiredMember,
  113. });
  114. MockApiClient.addMockResponse({
  115. url: `/organizations/${organization.slug}/members/${idpTeamMember.id}/`,
  116. body: idpTeamMember,
  117. });
  118. MockApiClient.addMockResponse({
  119. url: `/organizations/${organization.slug}/teams/`,
  120. body: teams,
  121. });
  122. });
  123. it('changes org role to owner', function () {
  124. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  125. context: routerContext,
  126. });
  127. // Should have 4 roles
  128. const radios = screen.getAllByRole('radio');
  129. expect(radios).toHaveLength(4);
  130. // Click last radio
  131. userEvent.click(radios.at(-1));
  132. expect(radios.at(-1)).toBeChecked();
  133. // Save Member
  134. userEvent.click(screen.getByRole('button', {name: 'Save Member'}));
  135. expect(updateMember).toHaveBeenCalledWith(
  136. expect.anything(),
  137. expect.objectContaining({
  138. data: expect.objectContaining({
  139. orgRole: 'owner',
  140. }),
  141. })
  142. );
  143. });
  144. it('leaves a team', function () {
  145. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  146. context: routerContext,
  147. });
  148. // Remove our one team
  149. userEvent.click(screen.getByRole('button', {name: 'Remove'}));
  150. // Save Member
  151. userEvent.click(screen.getByRole('button', {name: 'Save Member'}));
  152. expect(updateMember).toHaveBeenCalledWith(
  153. expect.anything(),
  154. expect.objectContaining({
  155. data: expect.objectContaining({
  156. teamRoles: [],
  157. }),
  158. })
  159. );
  160. });
  161. it('cannot leave idp-provisioned team', function () {
  162. render(<OrganizationMemberDetail params={{memberId: idpTeamMember.id}} />, {
  163. context: routerContext,
  164. });
  165. expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled();
  166. });
  167. it('joins a team and assign a team-role', async function () {
  168. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  169. context: routerContext,
  170. });
  171. // Should have one team enabled
  172. expect(screen.getByTestId('team-row-for-member')).toBeInTheDocument();
  173. // Select new team to join
  174. // Open the dropdown
  175. userEvent.click(screen.getByText('Add Team'));
  176. // Click the first item
  177. userEvent.click(screen.getByText('#new-team'));
  178. // Assign as admin to new team
  179. const teamRoleSelect = screen.getAllByText('Contributor')[1];
  180. await selectEvent.select(teamRoleSelect, ['Team Admin']);
  181. // Save Member
  182. userEvent.click(screen.getByRole('button', {name: 'Save Member'}));
  183. expect(updateMember).toHaveBeenCalledWith(
  184. expect.anything(),
  185. expect.objectContaining({
  186. data: expect.objectContaining({
  187. teamRoles: [
  188. {teamSlug: 'team-slug', role: null},
  189. {teamSlug: 'new-team', role: 'admin'},
  190. ],
  191. }),
  192. })
  193. );
  194. });
  195. it('cannot join idp-provisioned team', async function () {
  196. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  197. context: routerContext,
  198. });
  199. userEvent.click(screen.getByText('Add Team'));
  200. userEvent.hover(screen.queryByText('#idp-team'));
  201. expect(
  202. await screen.findByText(
  203. "Membership to this team is managed through your organization's identity provider."
  204. )
  205. ).toBeInTheDocument();
  206. });
  207. it('cannot change roles if member is idp-provisioned', function () {
  208. const roleRestrictedMember = TestStubs.Member({
  209. roles: TestStubs.OrgRoleList(),
  210. dateCreated: new Date(),
  211. teams: [team.slug],
  212. flags: {
  213. 'idp:role-restricted': true,
  214. },
  215. });
  216. MockApiClient.addMockResponse({
  217. url: `/organizations/${organization.slug}/members/${member.id}/`,
  218. body: roleRestrictedMember,
  219. });
  220. render(<OrganizationMemberDetail params={{memberId: roleRestrictedMember.id}} />, {
  221. context: routerContext,
  222. });
  223. const radios = screen.getAllByRole('radio');
  224. expect(radios.at(0)).toHaveAttribute('readonly');
  225. });
  226. });
  227. describe('Cannot Edit', function () {
  228. beforeEach(function () {
  229. organization = TestStubs.Organization({teams, access: ['org:read']});
  230. routerContext = TestStubs.routerContext([{organization}]);
  231. TeamStore.init();
  232. TeamStore.loadInitialData(teams);
  233. jest.resetAllMocks();
  234. MockApiClient.clearMockResponses();
  235. MockApiClient.addMockResponse({
  236. url: `/organizations/${organization.slug}/members/${member.id}/`,
  237. body: member,
  238. });
  239. MockApiClient.addMockResponse({
  240. url: `/organizations/${organization.slug}/members/${pendingMember.id}/`,
  241. body: pendingMember,
  242. });
  243. MockApiClient.addMockResponse({
  244. url: `/organizations/${organization.slug}/members/${expiredMember.id}/`,
  245. body: expiredMember,
  246. });
  247. MockApiClient.addMockResponse({
  248. url: `/organizations/${organization.slug}/teams/`,
  249. body: teams,
  250. });
  251. });
  252. it('can not change roles, teams, or save', function () {
  253. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  254. context: routerContext,
  255. });
  256. // Should have 4 roles
  257. const radios = screen.getAllByRole('radio');
  258. expect(radios.at(0)).toHaveAttribute('readonly');
  259. // Save Member
  260. expect(screen.getByRole('button', {name: 'Save Member'})).toBeDisabled();
  261. });
  262. });
  263. describe('Display status', function () {
  264. beforeEach(function () {
  265. organization = TestStubs.Organization({teams, access: ['org:read']});
  266. routerContext = TestStubs.routerContext([{organization}]);
  267. TeamStore.init();
  268. TeamStore.loadInitialData(teams);
  269. jest.resetAllMocks();
  270. MockApiClient.clearMockResponses();
  271. MockApiClient.addMockResponse({
  272. url: `/organizations/${organization.slug}/members/${member.id}/`,
  273. body: member,
  274. });
  275. MockApiClient.addMockResponse({
  276. url: `/organizations/${organization.slug}/members/${pendingMember.id}/`,
  277. body: pendingMember,
  278. });
  279. MockApiClient.addMockResponse({
  280. url: `/organizations/${organization.slug}/members/${expiredMember.id}/`,
  281. body: expiredMember,
  282. });
  283. MockApiClient.addMockResponse({
  284. url: `/organizations/${organization.slug}/teams/`,
  285. body: teams,
  286. });
  287. });
  288. it('display pending status', function () {
  289. render(<OrganizationMemberDetail params={{memberId: pendingMember.id}} />, {
  290. context: routerContext,
  291. });
  292. expect(screen.getByTestId('member-status')).toHaveTextContent('Invitation Pending');
  293. });
  294. it('display expired status', function () {
  295. render(<OrganizationMemberDetail params={{memberId: expiredMember.id}} />, {
  296. context: routerContext,
  297. });
  298. expect(screen.getByTestId('member-status')).toHaveTextContent('Invitation Expired');
  299. });
  300. });
  301. describe('Show resend button', function () {
  302. beforeEach(function () {
  303. organization = TestStubs.Organization({teams, access: ['org:read']});
  304. routerContext = TestStubs.routerContext([{organization}]);
  305. TeamStore.init();
  306. TeamStore.loadInitialData(teams);
  307. jest.resetAllMocks();
  308. MockApiClient.clearMockResponses();
  309. MockApiClient.addMockResponse({
  310. url: `/organizations/${organization.slug}/members/${member.id}/`,
  311. body: member,
  312. });
  313. MockApiClient.addMockResponse({
  314. url: `/organizations/${organization.slug}/members/${pendingMember.id}/`,
  315. body: pendingMember,
  316. });
  317. MockApiClient.addMockResponse({
  318. url: `/organizations/${organization.slug}/members/${expiredMember.id}/`,
  319. body: expiredMember,
  320. });
  321. MockApiClient.addMockResponse({
  322. url: `/organizations/${organization.slug}/teams/`,
  323. body: teams,
  324. });
  325. });
  326. it('shows for pending', function () {
  327. render(<OrganizationMemberDetail params={{memberId: pendingMember.id}} />, {
  328. context: routerContext,
  329. });
  330. expect(screen.getByRole('button', {name: 'Resend Invite'})).toBeInTheDocument();
  331. });
  332. it('does not show for expired', function () {
  333. render(<OrganizationMemberDetail params={{memberId: expiredMember.id}} />, {
  334. context: routerContext,
  335. });
  336. expect(
  337. screen.queryByRole('button', {name: 'Resend Invite'})
  338. ).not.toBeInTheDocument();
  339. });
  340. });
  341. describe('Reset member 2FA', function () {
  342. const fields = {
  343. roles: TestStubs.OrgRoleList(),
  344. dateCreated: new Date(),
  345. ...teamAssignment,
  346. };
  347. const noAccess = TestStubs.Member({
  348. ...fields,
  349. id: '4',
  350. user: TestStubs.User({has2fa: false}),
  351. });
  352. const no2fa = TestStubs.Member({
  353. ...fields,
  354. id: '5',
  355. user: TestStubs.User({has2fa: false, authenticators: [], canReset2fa: true}),
  356. });
  357. const has2fa = TestStubs.Member({
  358. ...fields,
  359. id: '6',
  360. user: TestStubs.User({
  361. has2fa: true,
  362. authenticators: [
  363. TestStubs.Authenticators().Totp(),
  364. TestStubs.Authenticators().Sms(),
  365. TestStubs.Authenticators().U2f(),
  366. ],
  367. canReset2fa: true,
  368. }),
  369. });
  370. const multipleOrgs = TestStubs.Member({
  371. ...fields,
  372. id: '7',
  373. user: TestStubs.User({
  374. has2fa: true,
  375. authenticators: [TestStubs.Authenticators().Totp()],
  376. canReset2fa: false,
  377. }),
  378. });
  379. beforeEach(function () {
  380. organization = TestStubs.Organization({teams});
  381. routerContext = TestStubs.routerContext([{organization}]);
  382. MockApiClient.clearMockResponses();
  383. MockApiClient.addMockResponse({
  384. url: `/organizations/${organization.slug}/members/${pendingMember.id}/`,
  385. body: pendingMember,
  386. });
  387. MockApiClient.addMockResponse({
  388. url: `/organizations/${organization.slug}/members/${noAccess.id}/`,
  389. body: noAccess,
  390. });
  391. MockApiClient.addMockResponse({
  392. url: `/organizations/${organization.slug}/members/${no2fa.id}/`,
  393. body: no2fa,
  394. });
  395. MockApiClient.addMockResponse({
  396. url: `/organizations/${organization.slug}/members/${has2fa.id}/`,
  397. body: has2fa,
  398. });
  399. MockApiClient.addMockResponse({
  400. url: `/organizations/${organization.slug}/members/${multipleOrgs.id}/`,
  401. body: multipleOrgs,
  402. });
  403. MockApiClient.addMockResponse({
  404. url: `/organizations/${organization.slug}/teams/`,
  405. body: teams,
  406. });
  407. });
  408. const button = () =>
  409. screen.queryByRole('button', {name: 'Reset two-factor authentication'});
  410. const tooltip = () => screen.queryByTestId('reset-2fa-tooltip');
  411. const expectButtonEnabled = () => {
  412. expect(button()).toHaveTextContent('Reset two-factor authentication');
  413. expect(button()).toBeEnabled();
  414. expect(tooltip()).not.toBeInTheDocument();
  415. };
  416. const expectButtonDisabled = async title => {
  417. expect(button()).toHaveTextContent('Reset two-factor authentication');
  418. expect(button()).toBeDisabled();
  419. userEvent.hover(button());
  420. expect(await screen.findByText(title)).toBeInTheDocument();
  421. };
  422. it('does not show for pending member', function () {
  423. render(<OrganizationMemberDetail params={{memberId: pendingMember.id}} />, {
  424. context: routerContext,
  425. });
  426. expect(button()).not.toBeInTheDocument();
  427. });
  428. it('shows tooltip for joined member without permission to edit', async function () {
  429. render(<OrganizationMemberDetail params={{memberId: noAccess.id}} />, {
  430. context: routerContext,
  431. });
  432. await expectButtonDisabled('You do not have permission to perform this action');
  433. });
  434. it('shows tooltip for member without 2fa', async function () {
  435. render(<OrganizationMemberDetail params={{memberId: no2fa.id}} />, {
  436. context: routerContext,
  437. });
  438. await expectButtonDisabled('Not enrolled in two-factor authentication');
  439. });
  440. it('can reset member 2FA', function () {
  441. const deleteMocks = has2fa.user.authenticators.map(auth =>
  442. MockApiClient.addMockResponse({
  443. url: `/users/${has2fa.user.id}/authenticators/${auth.id}/`,
  444. method: 'DELETE',
  445. })
  446. );
  447. render(<OrganizationMemberDetail params={{memberId: has2fa.id}} />, {
  448. context: routerContext,
  449. });
  450. renderGlobalModal();
  451. expectButtonEnabled();
  452. userEvent.click(button());
  453. userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
  454. deleteMocks.forEach(deleteMock => {
  455. expect(deleteMock).toHaveBeenCalled();
  456. });
  457. });
  458. it('shows tooltip for member in multiple orgs', async function () {
  459. render(<OrganizationMemberDetail params={{memberId: multipleOrgs.id}} />, {
  460. context: routerContext,
  461. });
  462. await expectButtonDisabled(
  463. 'Cannot be reset since user is in more than one organization'
  464. );
  465. });
  466. it('shows tooltip for member in 2FA required org', async function () {
  467. organization.require2FA = true;
  468. MockApiClient.addMockResponse({
  469. url: `/organizations/${organization.slug}/members/${has2fa.id}/`,
  470. body: has2fa,
  471. });
  472. render(<OrganizationMemberDetail params={{memberId: has2fa.id}} />, {
  473. context: routerContext,
  474. });
  475. await expectButtonDisabled(
  476. 'Cannot be reset since two-factor is required for this organization'
  477. );
  478. });
  479. });
  480. describe('Org Roles affect Team Roles', () => {
  481. // Org Admin will be deprecated
  482. const admin = TestStubs.Member({
  483. id: '4',
  484. role: 'admin',
  485. roleName: 'Admin',
  486. orgRole: 'admin',
  487. ...teamAssignment,
  488. });
  489. const manager = TestStubs.Member({
  490. id: '5',
  491. role: 'manager',
  492. roleName: 'Manager',
  493. orgRole: 'manager',
  494. ...teamAssignment,
  495. });
  496. const owner = TestStubs.Member({
  497. id: '6',
  498. role: 'owner',
  499. roleName: 'Owner',
  500. orgRole: 'owner',
  501. ...teamAssignment,
  502. });
  503. beforeAll(() => {
  504. organization = TestStubs.Organization({teams, features: ['team-roles']});
  505. routerContext = TestStubs.routerContext([{organization}]);
  506. });
  507. beforeEach(() => {
  508. MockApiClient.clearMockResponses();
  509. MockApiClient.addMockResponse({
  510. url: `/organizations/${organization.slug}/members/${member.id}/`,
  511. body: member,
  512. });
  513. MockApiClient.addMockResponse({
  514. url: `/organizations/${organization.slug}/members/${admin.id}/`,
  515. body: admin,
  516. });
  517. MockApiClient.addMockResponse({
  518. url: `/organizations/${organization.slug}/members/${manager.id}/`,
  519. body: manager,
  520. });
  521. MockApiClient.addMockResponse({
  522. url: `/organizations/${organization.slug}/members/${owner.id}/`,
  523. body: owner,
  524. });
  525. });
  526. it('does not overwrite team-roles for org members', async () => {
  527. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  528. context: routerContext,
  529. });
  530. // Role info box is hidden
  531. expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument();
  532. // Dropdown has correct value set
  533. const teamRow = screen.getByTestId('team-row-for-member');
  534. const teamRoleSelect = within(teamRow).getByText('Contributor');
  535. // Dropdown options are not visible
  536. expect(screen.queryAllByText('...').length).toBe(0);
  537. // Dropdown can be opened
  538. selectEvent.openMenu(teamRoleSelect);
  539. expect(screen.queryAllByText('...').length).toBe(2);
  540. // Dropdown value can be changed
  541. await selectEvent.select(teamRoleSelect, ['Team Admin']);
  542. expect(teamRoleSelect).toHaveTextContent('Team Admin');
  543. });
  544. it('overwrite team-roles for org admin/manager/owner', () => {
  545. function testForOrgRole(testMember) {
  546. cleanup();
  547. render(<OrganizationMemberDetail params={{memberId: testMember.id}} />, {
  548. context: routerContext,
  549. });
  550. // Role info box is showed
  551. expect(screen.queryByTestId('alert-role-overwrite')).toBeInTheDocument();
  552. // Dropdown has correct value set
  553. const teamRow = screen.getByTestId('team-row-for-member');
  554. const teamRoleSelect = within(teamRow).getByText('Team Admin');
  555. // Dropdown options are not visible
  556. expect(screen.queryAllByText('...').length).toBe(0);
  557. // Dropdown cannot be opened
  558. selectEvent.openMenu(teamRoleSelect);
  559. expect(screen.queryAllByText('...').length).toBe(0);
  560. }
  561. for (const role of [admin, manager, owner]) {
  562. testForOrgRole(role);
  563. }
  564. });
  565. it('overwrites when changing from member to manager', () => {
  566. render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
  567. context: routerContext,
  568. });
  569. // Role info box is hidden
  570. expect(screen.queryByTestId('alert-role-overwrite')).not.toBeInTheDocument();
  571. // Dropdown has correct value set
  572. const teamRow = screen.getByTestId('team-row-for-member');
  573. const teamRoleSelect = within(teamRow).getByText('Contributor');
  574. // Change member to owner
  575. const orgRoleRadio = screen.getAllByRole('radio');
  576. expect(orgRoleRadio).toHaveLength(4);
  577. userEvent.click(orgRoleRadio.at(-1));
  578. expect(orgRoleRadio.at(-1)).toBeChecked();
  579. // Role info box is shown
  580. expect(screen.queryByTestId('alert-role-overwrite')).toBeInTheDocument();
  581. // Dropdown has correct value set
  582. within(teamRow).getByText('Team Admin');
  583. // Dropdown options are not visible
  584. expect(screen.queryAllByText('...').length).toBe(0);
  585. // Dropdown cannot be opened
  586. selectEvent.openMenu(teamRoleSelect);
  587. expect(screen.queryAllByText('...').length).toBe(0);
  588. });
  589. });
  590. });