LdapUserPicker.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * Tests for LdapUserPicker (#1298).
  3. *
  4. * The picker is rendered inside the user-create modal when LDAP is enabled.
  5. * It owns its own search + provision mutation; the parent modal just provides
  6. * the onSuccess callback that closes the modal and toasts.
  7. */
  8. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  9. import { screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { render } from '../utils';
  12. import { LdapUserPicker } from '../../components/LdapUserPicker';
  13. import { api } from '../../api/client';
  14. vi.mock('../../api/client', () => ({
  15. api: {
  16. searchLDAPDirectory: vi.fn(),
  17. provisionLDAPUser: vi.fn(),
  18. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  19. getSettings: vi.fn().mockResolvedValue({}),
  20. },
  21. }));
  22. describe('LdapUserPicker', () => {
  23. beforeEach(() => {
  24. vi.useFakeTimers({ shouldAdvanceTime: true });
  25. });
  26. afterEach(() => {
  27. vi.useRealTimers();
  28. vi.clearAllMocks();
  29. });
  30. it('does not search until the user types at least 2 characters', async () => {
  31. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  32. render(<LdapUserPicker onSuccess={() => {}} />);
  33. const input = screen.getByPlaceholderText(/type a username/i);
  34. await user.type(input, 'a');
  35. // Advance well past the debounce window — a 1-char query must still not fire.
  36. await vi.advanceTimersByTimeAsync(1000);
  37. expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
  38. expect(screen.getByText(/at least 2 characters/i)).toBeInTheDocument();
  39. });
  40. it('debounces typing and only sends the final query', async () => {
  41. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  42. (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  43. render(<LdapUserPicker onSuccess={() => {}} />);
  44. const input = screen.getByPlaceholderText(/type a username/i);
  45. await user.type(input, 'jdoe');
  46. // After the last keystroke, the 300ms debounce hasn't elapsed yet — verify
  47. // we haven't fired a request for an intermediate value like 'jd' or 'jdo'.
  48. expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
  49. await vi.advanceTimersByTimeAsync(350);
  50. await waitFor(() => {
  51. expect(api.searchLDAPDirectory).toHaveBeenCalledTimes(1);
  52. expect(api.searchLDAPDirectory).toHaveBeenCalledWith('jdoe');
  53. });
  54. });
  55. it('renders search results and lets the admin select and provision one', async () => {
  56. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  57. (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
  58. {
  59. username: 'jdoe',
  60. email: 'jdoe@example.com',
  61. display_name: 'John Doe',
  62. dn: 'cn=John Doe,dc=example,dc=com',
  63. already_provisioned: false,
  64. },
  65. ]);
  66. (api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockResolvedValue({
  67. id: 42,
  68. username: 'jdoe',
  69. auth_source: 'ldap',
  70. groups: [],
  71. permissions: [],
  72. role: 'user',
  73. is_active: true,
  74. is_admin: false,
  75. email: 'jdoe@example.com',
  76. created_at: '2026-05-15T10:00:00Z',
  77. });
  78. const onSuccess = vi.fn();
  79. render(<LdapUserPicker onSuccess={onSuccess} />);
  80. await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
  81. await vi.advanceTimersByTimeAsync(350);
  82. // Result list renders with the username + display name visible.
  83. const resultRow = await screen.findByText('jdoe');
  84. expect(resultRow).toBeInTheDocument();
  85. expect(screen.getByText(/john doe/i)).toBeInTheDocument();
  86. await user.click(resultRow);
  87. // Submit button activates after selection. The label is "Provision user"
  88. // — match it specifically so we don't accidentally select the "Provisioning..."
  89. // loading variant.
  90. const submit = screen.getByRole('button', { name: /^provision user$/i });
  91. expect(submit).not.toBeDisabled();
  92. await user.click(submit);
  93. await waitFor(() => {
  94. expect(api.provisionLDAPUser).toHaveBeenCalledWith('jdoe');
  95. expect(onSuccess).toHaveBeenCalledTimes(1);
  96. expect(onSuccess.mock.calls[0][0].username).toBe('jdoe');
  97. });
  98. });
  99. it('disables already-provisioned rows so admins cannot pick them', async () => {
  100. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  101. (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
  102. {
  103. username: 'existing',
  104. email: 'existing@example.com',
  105. display_name: null,
  106. dn: 'cn=existing,dc=example,dc=com',
  107. already_provisioned: true,
  108. },
  109. ]);
  110. render(<LdapUserPicker onSuccess={() => {}} />);
  111. await user.type(screen.getByPlaceholderText(/type a username/i), 'existing');
  112. await vi.advanceTimersByTimeAsync(350);
  113. await waitFor(() => {
  114. expect(screen.getByText(/already provisioned/i)).toBeInTheDocument();
  115. });
  116. // The row's <button> is disabled — userEvent.click will throw, so we just
  117. // assert the disabled attribute is set, which is the contract that drives
  118. // the cursor + opacity styling.
  119. const rowButton = screen.getByText('existing').closest('button')!;
  120. expect(rowButton).toBeDisabled();
  121. // The submit button stays disabled because there's no selectable row.
  122. const submit = screen.getByRole('button', { name: /^provision user$/i });
  123. expect(submit).toBeDisabled();
  124. });
  125. it('surfaces provision errors instead of swallowing them', async () => {
  126. const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  127. (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
  128. {
  129. username: 'jdoe',
  130. email: null,
  131. display_name: null,
  132. dn: 'cn=jdoe,dc=example,dc=com',
  133. already_provisioned: false,
  134. },
  135. ]);
  136. (api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockRejectedValue(
  137. new Error('LDAP server unreachable')
  138. );
  139. const onSuccess = vi.fn();
  140. render(<LdapUserPicker onSuccess={onSuccess} />);
  141. await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
  142. await vi.advanceTimersByTimeAsync(350);
  143. await user.click(await screen.findByText('jdoe'));
  144. await user.click(screen.getByRole('button', { name: /^provision user$/i }));
  145. await waitFor(() => {
  146. expect(screen.getByText(/ldap server unreachable/i)).toBeInTheDocument();
  147. });
  148. expect(onSuccess).not.toHaveBeenCalled();
  149. });
  150. });