index.spec.jsx 10 KB

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