OIDCGroupSyncForm.test.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * Tests for the OIDC group-sync form (#3107): the row-based mapping editor,
  3. * the orphaned-row flagging for deleted groups, and the summary card's
  4. * Group Sync line. The scope-mismatch warning was removed in review, and
  5. * one test pins that it stays gone.
  6. */
  7. import { describe, it, expect, beforeEach } from 'vitest';
  8. import { screen, waitFor } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { render } from '../utils';
  11. import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
  12. import { http, HttpResponse } from 'msw';
  13. import { server } from '../mocks/server';
  14. const baseProvider = {
  15. id: 7,
  16. name: 'GroupSyncIdP',
  17. issuer_url: 'https://gs.example.com',
  18. client_id: 'gs-client',
  19. scopes: 'openid email profile',
  20. is_enabled: true,
  21. auto_create_users: false,
  22. auto_link_existing_accounts: false,
  23. email_claim: 'email',
  24. require_email_verified: true,
  25. group_claim: 'groups',
  26. group_mapping: {},
  27. icon_url: null,
  28. has_icon: false,
  29. default_group_id: null,
  30. is_autologin: false,
  31. created_at: '2026-01-01T00:00:00Z',
  32. updated_at: '2026-01-01T00:00:00Z',
  33. };
  34. const mockGroups = [
  35. { id: 1, name: 'Administrators', description: '', permissions: [], is_system: true },
  36. { id: 2, name: 'Operators', description: '', permissions: [], is_system: true },
  37. ];
  38. let savedPayload: Record<string, unknown> | null = null;
  39. function mockCreate() {
  40. server.use(
  41. http.post('/api/v1/auth/oidc/providers', async ({ request }) => {
  42. savedPayload = (await request.json()) as Record<string, unknown>;
  43. return HttpResponse.json({ ...baseProvider, id: 99, ...savedPayload }, { status: 201 });
  44. })
  45. );
  46. }
  47. beforeEach(() => {
  48. savedPayload = null;
  49. server.use(
  50. http.get('/api/v1/auth/oidc/providers/all', () =>
  51. HttpResponse.json([{ ...baseProvider }])
  52. ),
  53. http.get('/api/v1/groups/', () => HttpResponse.json(mockGroups))
  54. );
  55. });
  56. async function openCreateForm() {
  57. await waitFor(() => {
  58. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  59. });
  60. await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  61. }
  62. async function fillRequiredFields() {
  63. // The form's labels are not wired with htmlFor, so drive by placeholder
  64. // (same approach the existing OIDCProviderSettings test uses).
  65. await userEvent.type(screen.getByPlaceholderText('Google'), 'GS-IdP');
  66. await userEvent.type(screen.getByPlaceholderText('https://accounts.google.com'), 'https://gs.example.com');
  67. await userEvent.type(screen.getByPlaceholderText('your-client-id'), 'gs-client');
  68. await userEvent.type(screen.getByPlaceholderText(/new secret/i), 'gs-secret');
  69. }
  70. describe('OIDC group sync form', () => {
  71. it('adds a mapping row, fills the IdP name and picks a Bambuddy group', async () => {
  72. mockCreate();
  73. render(<OIDCProviderSettings />);
  74. await openCreateForm();
  75. await fillRequiredFields();
  76. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  77. const idpInput = screen.getByPlaceholderText(/Identity provider group name/i);
  78. await userEvent.type(idpInput, 'idp-ops');
  79. const selects = screen.getAllByDisplayValue(/Select Bambuddy group/i);
  80. await userEvent.selectOptions(selects[0], 'Operators');
  81. await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
  82. await waitFor(() => {
  83. expect(savedPayload).not.toBeNull();
  84. });
  85. expect(savedPayload!.group_mapping).toEqual({ 'idp-ops': 'Operators' });
  86. expect(savedPayload!.group_claim).toBe('groups');
  87. });
  88. it('saves an empty mapping as {} when no rows are added', async () => {
  89. mockCreate();
  90. render(<OIDCProviderSettings />);
  91. await openCreateForm();
  92. await fillRequiredFields();
  93. await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
  94. await waitFor(() => {
  95. expect(savedPayload).not.toBeNull();
  96. });
  97. expect(savedPayload!.group_mapping).toEqual({});
  98. });
  99. it('removes a row with its trash button and the mapping is not sent', async () => {
  100. mockCreate();
  101. render(<OIDCProviderSettings />);
  102. await openCreateForm();
  103. await fillRequiredFields();
  104. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  105. const idpInput = screen.getByPlaceholderText(/Identity provider group name/i);
  106. await userEvent.type(idpInput, 'idp-ops');
  107. // The row's trash button is the button inside the same row container
  108. const row = idpInput.closest('div')?.parentElement;
  109. const trash = row?.querySelector('button');
  110. expect(trash).not.toBeNull();
  111. await userEvent.click(trash!);
  112. await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
  113. await waitFor(() => {
  114. expect(savedPayload).not.toBeNull();
  115. });
  116. expect(savedPayload!.group_mapping).toEqual({});
  117. });
  118. it('flags a row with no Bambuddy group and keeps Save disabled instead of dropping it', async () => {
  119. mockCreate();
  120. render(<OIDCProviderSettings />);
  121. await openCreateForm();
  122. await fillRequiredFields();
  123. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  124. await userEvent.type(screen.getByPlaceholderText(/Identity provider group name/i), 'idp-ops');
  125. expect(screen.getByText(/pick a Bambuddy group, or remove this mapping/i)).toBeInTheDocument();
  126. expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
  127. await userEvent.selectOptions(screen.getAllByDisplayValue(/Select Bambuddy group/i)[0], 'Operators');
  128. expect(screen.queryByText(/pick a Bambuddy group, or remove this mapping/i)).not.toBeInTheDocument();
  129. expect(screen.getByRole('button', { name: /^Save$/i })).toBeEnabled();
  130. });
  131. it('flags a second row for the same IdP group, ignoring case', async () => {
  132. mockCreate();
  133. render(<OIDCProviderSettings />);
  134. await openCreateForm();
  135. await fillRequiredFields();
  136. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  137. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  138. const idpInputs = screen.getAllByPlaceholderText(/Identity provider group name/i);
  139. await userEvent.type(idpInputs[0], 'Admins');
  140. await userEvent.type(idpInputs[1], 'admins');
  141. const selects = screen.getAllByDisplayValue(/Select Bambuddy group/i);
  142. await userEvent.selectOptions(selects[0], 'Administrators');
  143. await userEvent.selectOptions(selects[1], 'Operators');
  144. expect(screen.getAllByText(/already mapped above/i)).toHaveLength(1);
  145. expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
  146. });
  147. it('skips a row that was added but left completely empty', async () => {
  148. mockCreate();
  149. render(<OIDCProviderSettings />);
  150. await openCreateForm();
  151. await fillRequiredFields();
  152. await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
  153. await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
  154. await waitFor(() => {
  155. expect(savedPayload).not.toBeNull();
  156. });
  157. expect(savedPayload!.group_mapping).toEqual({});
  158. });
  159. it('shows the stale group as a deleted option when the mapping names a removed group', async () => {
  160. server.use(
  161. http.get('/api/v1/auth/oidc/providers/all', () =>
  162. HttpResponse.json([
  163. {
  164. ...baseProvider,
  165. group_mapping: { 'idp-ops': 'DeletedGroup' },
  166. },
  167. ])
  168. )
  169. );
  170. render(<OIDCProviderSettings />);
  171. // Summary card shows Group Sync on
  172. await waitFor(() => {
  173. expect(screen.getByText(/Group Sync/i)).toBeInTheDocument();
  174. });
  175. // Open the edit form: the orphaned row must be flagged
  176. const editButton = await screen.findByTestId('edit-provider-7');
  177. await userEvent.click(editButton);
  178. // The orphaned row shows the stale name as a "(deleted)" option AND the
  179. // standalone warning line; assert on the warning's distinctive text.
  180. await waitFor(() => {
  181. expect(screen.getByText(/has been deleted/i)).toBeInTheDocument();
  182. });
  183. expect(screen.getByText(/DeletedGroup \(deleted\)/i)).toBeInTheDocument();
  184. });
  185. it('shows Group Sync on the summary card only when a mapping exists', async () => {
  186. render(<OIDCProviderSettings />);
  187. await waitFor(() => {
  188. expect(screen.getByText(/Group Sync/i)).toBeInTheDocument();
  189. });
  190. // base provider has empty mapping -> the status reads Off, not On
  191. expect(screen.queryByText(/\(groups\)/)).not.toBeInTheDocument();
  192. });
  193. it('does not show a scopes warning on the claim fields (removed in review)', async () => {
  194. render(<OIDCProviderSettings />);
  195. await openCreateForm();
  196. // With stock scopes "openid email profile" and claim "groups", the removed
  197. // warning must not render anywhere in the form.
  198. expect(
  199. screen.queryByText(/won't be returned by your identity provider/i)
  200. ).not.toBeInTheDocument();
  201. });
  202. });