index.spec.tsx 11 KB

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