index.spec.tsx 12 KB

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