index.spec.tsx 11 KB

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