index.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import {AccountEmails} from 'sentry-fixture/accountEmails';
  2. import {Authenticators} from 'sentry-fixture/authenticators';
  3. import {Organizations} from 'sentry-fixture/organizations';
  4. import RouterContextFixture from 'sentry-fixture/routerContextFixture';
  5. import {
  6. render,
  7. renderGlobalModal,
  8. screen,
  9. userEvent,
  10. waitFor,
  11. } from 'sentry-test/reactTestingLibrary';
  12. import ModalStore from 'sentry/stores/modalStore';
  13. import AccountSecurity from 'sentry/views/settings/account/accountSecurity';
  14. import AccountSecurityWrapper from 'sentry/views/settings/account/accountSecurity/accountSecurityWrapper';
  15. const ENDPOINT = '/users/me/authenticators/';
  16. const ORG_ENDPOINT = '/organizations/';
  17. const ACCOUNT_EMAILS_ENDPOINT = '/users/me/emails/';
  18. const AUTH_ENDPOINT = '/auth/';
  19. describe('AccountSecurity', function () {
  20. const router = TestStubs.router();
  21. beforeEach(function () {
  22. jest.spyOn(window.location, 'assign').mockImplementation(() => {});
  23. MockApiClient.clearMockResponses();
  24. MockApiClient.addMockResponse({
  25. url: ORG_ENDPOINT,
  26. body: Organizations(),
  27. });
  28. MockApiClient.addMockResponse({
  29. url: ACCOUNT_EMAILS_ENDPOINT,
  30. body: AccountEmails(),
  31. });
  32. });
  33. afterEach(function () {
  34. (window.location.assign as jest.Mock).mockRestore();
  35. });
  36. function renderComponent() {
  37. return render(
  38. <AccountSecurityWrapper
  39. location={router.location}
  40. route={router.routes[0]}
  41. routes={router.routes}
  42. router={router}
  43. routeParams={router.params}
  44. params={{...router.params, authId: '15'}}
  45. >
  46. <AccountSecurity
  47. deleteDisabled={false}
  48. authenticators={[]}
  49. hasVerifiedEmail
  50. countEnrolled={0}
  51. handleRefresh={jest.fn()}
  52. onDisable={jest.fn()}
  53. orgsRequire2fa={[]}
  54. location={router.location}
  55. route={router.routes[0]}
  56. routes={router.routes}
  57. router={router}
  58. routeParams={router.params}
  59. params={{...router.params, authId: '15'}}
  60. />
  61. </AccountSecurityWrapper>,
  62. {context: RouterContextFixture()}
  63. );
  64. }
  65. it('renders empty', async function () {
  66. MockApiClient.addMockResponse({
  67. url: ENDPOINT,
  68. body: [],
  69. });
  70. renderComponent();
  71. expect(
  72. await screen.findByText('No available authenticators to add')
  73. ).toBeInTheDocument();
  74. });
  75. it('renders a primary interface that is enrolled', async function () {
  76. MockApiClient.addMockResponse({
  77. url: ENDPOINT,
  78. body: [Authenticators().Totp({configureButton: 'Info'})],
  79. });
  80. renderComponent();
  81. expect(await screen.findByText('Authenticator App')).toBeInTheDocument();
  82. expect(screen.getByRole('button', {name: 'Info'})).toBeInTheDocument();
  83. expect(screen.getByRole('button', {name: 'Delete'})).toBeInTheDocument();
  84. expect(
  85. screen.getByRole('status', {name: 'Authentication Method Active'})
  86. ).toBeInTheDocument();
  87. });
  88. it('can delete enrolled authenticator', async function () {
  89. MockApiClient.addMockResponse({
  90. url: ENDPOINT,
  91. body: [
  92. Authenticators().Totp({
  93. authId: '15',
  94. configureButton: 'Info',
  95. }),
  96. ],
  97. });
  98. const deleteMock = MockApiClient.addMockResponse({
  99. url: `${ENDPOINT}15/`,
  100. method: 'DELETE',
  101. });
  102. renderComponent();
  103. expect(deleteMock).not.toHaveBeenCalled();
  104. expect(
  105. await screen.findByRole('status', {name: 'Authentication Method Active'})
  106. ).toBeInTheDocument();
  107. // next authenticators request should have totp disabled
  108. const authenticatorsMock = MockApiClient.addMockResponse({
  109. url: ENDPOINT,
  110. body: [
  111. Authenticators().Totp({
  112. isEnrolled: false,
  113. authId: '15',
  114. configureButton: 'Info',
  115. }),
  116. ],
  117. });
  118. await userEvent.click(screen.getByRole('button', {name: 'Delete'}));
  119. renderGlobalModal();
  120. await userEvent.click(screen.getByTestId('confirm-button'));
  121. // Should only have been called once
  122. await waitFor(() => expect(authenticatorsMock).toHaveBeenCalledTimes(1));
  123. expect(deleteMock).toHaveBeenCalled();
  124. expect(
  125. screen.getByRole('status', {name: 'Authentication Method Inactive'})
  126. ).toBeInTheDocument();
  127. });
  128. it('can remove one of multiple 2fa methods when org requires 2fa', async function () {
  129. MockApiClient.addMockResponse({
  130. url: ENDPOINT,
  131. body: [
  132. Authenticators().Totp({
  133. authId: '15',
  134. configureButton: 'Info',
  135. }),
  136. Authenticators().U2f(),
  137. ],
  138. });
  139. MockApiClient.addMockResponse({
  140. url: ORG_ENDPOINT,
  141. body: Organizations({require2FA: true}),
  142. });
  143. const deleteMock = MockApiClient.addMockResponse({
  144. url: `${ENDPOINT}15/`,
  145. method: 'DELETE',
  146. });
  147. expect(deleteMock).not.toHaveBeenCalled();
  148. renderComponent();
  149. expect(
  150. await screen.findAllByRole('status', {name: 'Authentication Method Active'})
  151. ).toHaveLength(2);
  152. await userEvent.click(screen.getAllByRole('button', {name: 'Delete'})[0]);
  153. renderGlobalModal();
  154. await userEvent.click(screen.getByTestId('confirm-button'));
  155. expect(deleteMock).toHaveBeenCalled();
  156. });
  157. it('can not remove last 2fa method when org requires 2fa', async function () {
  158. MockApiClient.addMockResponse({
  159. url: ENDPOINT,
  160. body: [
  161. Authenticators().Totp({
  162. authId: '15',
  163. configureButton: 'Info',
  164. }),
  165. ],
  166. });
  167. MockApiClient.addMockResponse({
  168. url: ORG_ENDPOINT,
  169. body: Organizations({require2FA: true}),
  170. });
  171. const deleteMock = MockApiClient.addMockResponse({
  172. url: `${ENDPOINT}15/`,
  173. method: 'DELETE',
  174. });
  175. renderComponent();
  176. expect(deleteMock).not.toHaveBeenCalled();
  177. expect(
  178. await screen.findByRole('status', {name: 'Authentication Method Active'})
  179. ).toBeInTheDocument();
  180. await userEvent.hover(screen.getByRole('button', {name: 'Delete'}));
  181. expect(screen.getByRole('button', {name: 'Delete'})).toBeDisabled();
  182. expect(
  183. await screen.findByText(
  184. 'Two-factor authentication is required for organization(s): test 1 and test 2.'
  185. )
  186. ).toBeInTheDocument();
  187. });
  188. it('cannot enroll without verified email', async function () {
  189. MockApiClient.addMockResponse({
  190. url: ENDPOINT,
  191. body: [Authenticators().Totp({isEnrolled: false})],
  192. });
  193. MockApiClient.addMockResponse({
  194. url: ACCOUNT_EMAILS_ENDPOINT,
  195. body: [
  196. {
  197. email: 'primary@example.com',
  198. isPrimary: true,
  199. isVerified: false,
  200. },
  201. ],
  202. });
  203. renderComponent();
  204. const openEmailModalFunc = jest.spyOn(ModalStore, 'openModal');
  205. expect(
  206. await screen.findByRole('status', {name: 'Authentication Method Inactive'})
  207. ).toBeInTheDocument();
  208. await userEvent.click(screen.getByRole('button', {name: 'Add'}));
  209. await waitFor(() => expect(openEmailModalFunc).toHaveBeenCalled());
  210. });
  211. it('renders a backup interface that is not enrolled', async function () {
  212. MockApiClient.addMockResponse({
  213. url: ENDPOINT,
  214. body: [Authenticators().Recovery({isEnrolled: false})],
  215. });
  216. renderComponent();
  217. expect(
  218. await screen.findByRole('status', {name: 'Authentication Method Inactive'})
  219. ).toBeInTheDocument();
  220. expect(screen.getByText('Recovery Codes')).toBeInTheDocument();
  221. });
  222. it('renders a primary interface that is not enrolled', async function () {
  223. MockApiClient.addMockResponse({
  224. url: ENDPOINT,
  225. body: [Authenticators().Totp({isEnrolled: false})],
  226. });
  227. renderComponent();
  228. expect(
  229. await screen.findByRole('status', {name: 'Authentication Method Inactive'})
  230. ).toBeInTheDocument();
  231. expect(screen.getByText('Authenticator App')).toBeInTheDocument();
  232. });
  233. it('does not render primary interface that disallows new enrollments', async function () {
  234. MockApiClient.addMockResponse({
  235. url: ENDPOINT,
  236. body: [
  237. Authenticators().Totp({disallowNewEnrollment: false}),
  238. Authenticators().U2f({disallowNewEnrollment: undefined}),
  239. Authenticators().Sms({disallowNewEnrollment: true}),
  240. ],
  241. });
  242. renderComponent();
  243. expect(await screen.findByText('Authenticator App')).toBeInTheDocument();
  244. expect(screen.getByText('U2F (Universal 2nd Factor)')).toBeInTheDocument();
  245. expect(screen.queryByText('Text Message')).not.toBeInTheDocument();
  246. });
  247. it('renders primary interface if new enrollments are disallowed, but we are enrolled', async function () {
  248. MockApiClient.addMockResponse({
  249. url: ENDPOINT,
  250. body: [Authenticators().Sms({isEnrolled: true, disallowNewEnrollment: true})],
  251. });
  252. renderComponent();
  253. // Should still render the authenticator since we are already enrolled
  254. expect(await screen.findByText('Text Message')).toBeInTheDocument();
  255. });
  256. it('renders a backup interface that is enrolled', async function () {
  257. MockApiClient.addMockResponse({
  258. url: ENDPOINT,
  259. body: [Authenticators().Recovery({isEnrolled: true})],
  260. });
  261. renderComponent();
  262. expect(await screen.findByText('Recovery Codes')).toBeInTheDocument();
  263. expect(screen.getByRole('button', {name: 'View Codes'})).toBeEnabled();
  264. });
  265. it('can change password', async function () {
  266. MockApiClient.addMockResponse({
  267. url: ENDPOINT,
  268. body: [Authenticators().Recovery({isEnrolled: false})],
  269. });
  270. const url = '/users/me/password/';
  271. const mock = MockApiClient.addMockResponse({
  272. url,
  273. method: 'PUT',
  274. });
  275. renderComponent();
  276. await userEvent.type(
  277. await screen.findByRole('textbox', {name: 'Current Password'}),
  278. 'oldpassword'
  279. );
  280. await userEvent.type(
  281. screen.getByRole('textbox', {name: 'New Password'}),
  282. 'newpassword'
  283. );
  284. await userEvent.type(
  285. screen.getByRole('textbox', {name: 'Verify New Password'}),
  286. 'newpassword'
  287. );
  288. await userEvent.click(screen.getByRole('button', {name: 'Change password'}));
  289. expect(mock).toHaveBeenCalledWith(
  290. url,
  291. expect.objectContaining({
  292. method: 'PUT',
  293. data: {
  294. password: 'oldpassword',
  295. passwordNew: 'newpassword',
  296. passwordVerify: 'newpassword',
  297. },
  298. })
  299. );
  300. });
  301. it('requires current password to be entered', async function () {
  302. MockApiClient.addMockResponse({
  303. url: ENDPOINT,
  304. body: [Authenticators().Recovery({isEnrolled: false})],
  305. });
  306. const url = '/users/me/password/';
  307. const mock = MockApiClient.addMockResponse({
  308. url,
  309. method: 'PUT',
  310. });
  311. renderComponent();
  312. await userEvent.type(
  313. await screen.findByRole('textbox', {name: 'New Password'}),
  314. 'newpassword'
  315. );
  316. await userEvent.type(
  317. screen.getByRole('textbox', {name: 'Verify New Password'}),
  318. 'newpassword'
  319. );
  320. await userEvent.click(screen.getByRole('button', {name: 'Change password'}));
  321. expect(mock).not.toHaveBeenCalled();
  322. });
  323. it('can expire all sessions', async function () {
  324. MockApiClient.addMockResponse({
  325. url: ENDPOINT,
  326. body: [Authenticators().Recovery({isEnrolled: false})],
  327. });
  328. const mock = MockApiClient.addMockResponse({
  329. url: AUTH_ENDPOINT,
  330. body: {all: true},
  331. method: 'DELETE',
  332. status: 204,
  333. });
  334. renderComponent();
  335. await userEvent.click(
  336. await screen.findByRole('button', {name: 'Sign out of all devices'})
  337. );
  338. expect(mock).toHaveBeenCalled();
  339. await waitFor(() =>
  340. expect(window.location.assign).toHaveBeenCalledWith('/auth/login/')
  341. );
  342. });
  343. });