AdminDisableTwoFactor.test.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * Admin "Disable 2FA" in the Edit user dialog.
  3. *
  4. * The wiki has long told admins to remove a locked-out user's 2FA from Edit
  5. * user, but there was no control for it, and the client helper sent no body,
  6. * so the admin password the route requires never reached it.
  7. */
  8. import { http, HttpResponse } from 'msw';
  9. import { describe, it, expect, vi, beforeEach } from 'vitest';
  10. import userEvent from '@testing-library/user-event';
  11. import { render, screen, waitFor } from '../utils';
  12. import { server } from '../mocks/server';
  13. const mockShowToast = vi.fn();
  14. const mockAuth = {
  15. user: { id: 1, username: 'admin', auth_source: 'local' } as { id: number; username: string; auth_source: string },
  16. authEnabled: true,
  17. isAdmin: true,
  18. };
  19. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  20. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  21. return {
  22. ...actual,
  23. useAuth: () => ({
  24. ...mockAuth,
  25. loading: false,
  26. hasPermission: () => true,
  27. hasAnyPermission: () => true,
  28. canModify: () => true,
  29. login: vi.fn(),
  30. loginWithToken: vi.fn(),
  31. logout: vi.fn(),
  32. refreshAuth: vi.fn(),
  33. }),
  34. };
  35. });
  36. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  37. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  38. return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
  39. });
  40. import { AdminDisableTwoFactor } from '../../components/AdminDisableTwoFactor';
  41. function captureDelete(response: object = { message: 'ok', had_2fa: true }, status = 200) {
  42. const calls: { id: string; body: unknown }[] = [];
  43. server.use(
  44. http.delete('/api/v1/auth/2fa/admin/:id', async ({ request, params }) => {
  45. calls.push({ id: String(params.id), body: await request.json() });
  46. return HttpResponse.json(response, { status });
  47. }),
  48. );
  49. return calls;
  50. }
  51. describe('AdminDisableTwoFactor', () => {
  52. beforeEach(() => {
  53. mockShowToast.mockReset();
  54. mockAuth.user = { id: 1, username: 'admin', auth_source: 'local' };
  55. mockAuth.isAdmin = true;
  56. mockAuth.authEnabled = true;
  57. });
  58. it('sends the admin password and says the 2FA was removed', async () => {
  59. const calls = captureDelete();
  60. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  61. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  62. await userEvent.type(screen.getByLabelText('Your password'), 'admin-pass');
  63. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  64. await waitFor(() => expect(calls).toHaveLength(1));
  65. expect(calls[0]).toEqual({ id: '7', body: { admin_password: 'admin-pass' } });
  66. await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Two-factor authentication removed for bob', 'success'));
  67. });
  68. it('says so when the user had no 2FA', async () => {
  69. captureDelete({ message: 'ok', had_2fa: false });
  70. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  71. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  72. await userEvent.type(screen.getByLabelText('Your password'), 'admin-pass');
  73. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  74. await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('bob had no two-factor authentication to remove', 'info'));
  75. });
  76. it('cannot confirm without a password', async () => {
  77. const calls = captureDelete();
  78. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  79. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  80. expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
  81. expect(calls).toHaveLength(0);
  82. });
  83. it('asks no password of an admin without a local one', async () => {
  84. mockAuth.user = { id: 1, username: 'sso-admin', auth_source: 'oidc' };
  85. const calls = captureDelete();
  86. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  87. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  88. expect(screen.queryByLabelText('Your password')).not.toBeInTheDocument();
  89. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  90. await waitFor(() => expect(calls).toHaveLength(1));
  91. expect(calls[0].body).toEqual({ admin_password: null });
  92. });
  93. it('asks for the password when an SSO admin turns out to have one', async () => {
  94. // An OIDC account can be given a password in Edit user; the route then asks for it.
  95. mockAuth.user = { id: 1, username: 'sso-admin', auth_source: 'oidc' };
  96. const calls: unknown[] = [];
  97. server.use(
  98. http.delete('/api/v1/auth/2fa/admin/:id', async ({ request }) => {
  99. const body = (await request.json()) as { admin_password: string | null };
  100. calls.push(body);
  101. if (!body.admin_password) return HttpResponse.json({ detail: 'Admin password required' }, { status: 401 });
  102. return HttpResponse.json({ message: 'ok', had_2fa: true });
  103. }),
  104. );
  105. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  106. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  107. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  108. await userEvent.type(await screen.findByLabelText('Your password'), 'sso-local-pass');
  109. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  110. await waitFor(() => expect(calls).toEqual([{ admin_password: null }, { admin_password: 'sso-local-pass' }]));
  111. await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Two-factor authentication removed for bob', 'success'));
  112. });
  113. it('shows the server refusal', async () => {
  114. captureDelete({ detail: 'Admin password required' }, 401);
  115. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  116. await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
  117. await userEvent.type(screen.getByLabelText('Your password'), 'wrong');
  118. await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
  119. await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining('Admin password required'), 'error'));
  120. });
  121. it('is not offered to a non-admin', () => {
  122. mockAuth.isAdmin = false;
  123. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  124. expect(screen.queryByRole('button', { name: 'Disable 2FA' })).not.toBeInTheDocument();
  125. });
  126. it('is not offered on your own account, which has its own 2FA settings', () => {
  127. render(<AdminDisableTwoFactor userId={1} username="admin" />);
  128. expect(screen.queryByRole('button', { name: 'Disable 2FA' })).not.toBeInTheDocument();
  129. });
  130. it('is not offered with authentication off', () => {
  131. mockAuth.authEnabled = false;
  132. render(<AdminDisableTwoFactor userId={7} username="bob" />);
  133. expect(screen.queryByRole('button', { name: 'Disable 2FA' })).not.toBeInTheDocument();
  134. });
  135. });