OIDCProviderSettings.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * Tests for OIDCProviderSettings — focused on the auto_link / require_email_verified
  3. * toggle interaction (SEC-1/SEC-6 UI enforcement).
  4. */
  5. import { describe, it, expect, beforeEach } from 'vitest';
  6. import { screen, waitFor, fireEvent } from '@testing-library/react';
  7. import userEvent from '@testing-library/user-event';
  8. import { render } from '../utils';
  9. import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
  10. import { http, HttpResponse } from 'msw';
  11. import { server } from '../mocks/server';
  12. const mockProviders = [
  13. {
  14. id: 1,
  15. name: 'TestIdP',
  16. issuer_url: 'https://idp.example.com',
  17. client_id: 'test-client',
  18. scopes: 'openid email profile',
  19. is_enabled: true,
  20. auto_create_users: false,
  21. auto_link_existing_accounts: false,
  22. email_claim: 'email',
  23. require_email_verified: true,
  24. icon_url: null,
  25. has_icon: false,
  26. default_group_id: null,
  27. created_at: '2026-01-01T00:00:00Z',
  28. updated_at: '2026-01-01T00:00:00Z',
  29. },
  30. ];
  31. beforeEach(() => {
  32. server.use(
  33. http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json(mockProviders))
  34. );
  35. });
  36. describe('OIDCProviderSettings', () => {
  37. describe('ProviderForm — require_email_verified description logic', () => {
  38. it('shows standard description when require_email_verified is on and auto_link is off', async () => {
  39. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  40. render(<OIDCProviderSettings />);
  41. await waitFor(() => {
  42. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  43. });
  44. await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  45. await waitFor(() => {
  46. // Default state: require_email_verified=true, auto_link=false → standard description
  47. expect(
  48. screen.getByText(/only.*accept.*email.*verified/i)
  49. ).toBeInTheDocument();
  50. });
  51. });
  52. it('shows "Disable auto-link first" description when auto_link is enabled', async () => {
  53. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  54. const user = userEvent.setup();
  55. render(<OIDCProviderSettings />);
  56. await waitFor(() => {
  57. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  58. });
  59. await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  60. await waitFor(() => {
  61. expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
  62. });
  63. // Find the Auto Link switch by aria-label or by position
  64. const switches = screen.getAllByRole('switch');
  65. // Switches order in form: Enabled, AutoCreate, AutoLink, RequireEmailVerified
  66. // AutoLink is the 3rd switch (index 2)
  67. const autoLinkSwitch = switches[2];
  68. await user.click(autoLinkSwitch);
  69. await waitFor(() => {
  70. expect(
  71. screen.getByText(/disable auto.?link first/i)
  72. ).toBeInTheDocument();
  73. });
  74. });
  75. it('shows warning text when require_email_verified is toggled off', async () => {
  76. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  77. const user = userEvent.setup();
  78. render(<OIDCProviderSettings />);
  79. await waitFor(() => {
  80. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  81. });
  82. await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  83. await waitFor(() => {
  84. expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
  85. });
  86. // RequireEmailVerified is the 4th switch (index 3)
  87. const switches = screen.getAllByRole('switch');
  88. const reqEvSwitch = switches[3];
  89. await user.click(reqEvSwitch);
  90. await waitFor(() => {
  91. expect(
  92. screen.getByText(/warning.*accept.*without.*verif/i)
  93. ).toBeInTheDocument();
  94. });
  95. });
  96. it('shows security warning when auto_link is enabled with a custom email claim', async () => {
  97. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  98. const user = userEvent.setup();
  99. render(<OIDCProviderSettings />);
  100. await waitFor(() => {
  101. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  102. });
  103. await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  104. await waitFor(() => {
  105. expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
  106. });
  107. // Enable auto_link (switch index 2)
  108. const autoLinkSwitch = screen.getAllByRole('switch')[2];
  109. await user.click(autoLinkSwitch);
  110. // Change email claim to a custom value via fireEvent to bypass the onChange fallback
  111. const emailClaimInput = screen.getByPlaceholderText('email');
  112. fireEvent.change(emailClaimInput, { target: { value: 'preferred_username' } });
  113. await waitFor(() => {
  114. expect(screen.getByText(/tenant-administered/i)).toBeInTheDocument();
  115. });
  116. });
  117. });
  118. describe('Provider info view', () => {
  119. it('renders email_claim and require_email_verified fields in provider details', async () => {
  120. render(<OIDCProviderSettings />);
  121. await waitFor(() => {
  122. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  123. });
  124. // The provider card shows field labels in the details section
  125. expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
  126. expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
  127. });
  128. it('renders Default Group label in provider details', async () => {
  129. render(<OIDCProviderSettings />);
  130. await waitFor(() => {
  131. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  132. });
  133. expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
  134. });
  135. it('shows Viewers fallback label when default_group_id is null', async () => {
  136. render(<OIDCProviderSettings />);
  137. await waitFor(() => {
  138. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  139. });
  140. // null default_group_id should display the Viewers fallback text
  141. expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
  142. });
  143. it('shows group name when default_group_id matches a known group', async () => {
  144. server.use(
  145. http.get('/api/v1/auth/oidc/providers/all', () =>
  146. HttpResponse.json([{ ...mockProviders[0], default_group_id: 2 }])
  147. )
  148. );
  149. render(<OIDCProviderSettings />);
  150. await waitFor(() => {
  151. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  152. });
  153. // default_group_id=2 matches Operators in the global MSW mock
  154. expect(screen.getByText('Operators')).toBeInTheDocument();
  155. });
  156. });
  157. describe('ProviderForm — default group dropdown', () => {
  158. it('renders a Default Group select in the create form', async () => {
  159. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  160. render(<OIDCProviderSettings />);
  161. await waitFor(() => {
  162. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  163. });
  164. await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  165. await waitFor(() => {
  166. expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
  167. });
  168. // Dropdown should render with Viewers fallback option
  169. const select = screen.getByRole('combobox');
  170. expect(select).toBeInTheDocument();
  171. expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
  172. });
  173. it('populates Default Group dropdown with groups from API', async () => {
  174. server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
  175. render(<OIDCProviderSettings />);
  176. await waitFor(() => {
  177. expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
  178. });
  179. await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
  180. await waitFor(() => {
  181. // Global MSW mock returns Administrators, Operators, Viewers
  182. const options = screen.getAllByRole('option');
  183. const optionTexts = options.map((o) => o.textContent);
  184. expect(optionTexts).toContain('Operators');
  185. expect(optionTexts).toContain('Administrators');
  186. });
  187. });
  188. });
  189. // #1333: icon proxy — preview uses the backend proxy URL (never icon_url
  190. // directly) and the admin gets explicit Refresh / Remove buttons.
  191. describe('Icon proxy (#1333)', () => {
  192. it('renders icon preview via the backend proxy URL when has_icon is true', async () => {
  193. server.use(
  194. http.get('/api/v1/auth/oidc/providers/all', () =>
  195. HttpResponse.json([
  196. {
  197. ...mockProviders[0],
  198. id: 42,
  199. icon_url: 'https://idp.example.com/icon.png',
  200. has_icon: true,
  201. },
  202. ])
  203. )
  204. );
  205. render(<OIDCProviderSettings />);
  206. await waitFor(() => {
  207. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  208. });
  209. const img = screen.getByAltText('TestIdP') as HTMLImageElement;
  210. expect(img.getAttribute('src')).toBe('/api/v1/auth/oidc/providers/42/icon');
  211. });
  212. it('exposes Refresh and Remove buttons when has_icon is true', async () => {
  213. server.use(
  214. http.get('/api/v1/auth/oidc/providers/all', () =>
  215. HttpResponse.json([
  216. { ...mockProviders[0], id: 99, icon_url: 'https://idp.example.com/i.png', has_icon: true },
  217. ])
  218. )
  219. );
  220. render(<OIDCProviderSettings />);
  221. await waitFor(() => {
  222. expect(screen.getByTestId('refresh-icon-99')).toBeInTheDocument();
  223. });
  224. expect(screen.getByTestId('remove-icon-99')).toBeInTheDocument();
  225. });
  226. it('hides Remove button when has_icon is false', async () => {
  227. server.use(
  228. http.get('/api/v1/auth/oidc/providers/all', () =>
  229. HttpResponse.json([
  230. // icon_url set but no cached bytes → Refresh visible, Remove hidden.
  231. { ...mockProviders[0], id: 100, icon_url: 'https://idp.example.com/i.png', has_icon: false },
  232. ])
  233. )
  234. );
  235. render(<OIDCProviderSettings />);
  236. await waitFor(() => {
  237. expect(screen.getByTestId('refresh-icon-100')).toBeInTheDocument();
  238. });
  239. expect(screen.queryByTestId('remove-icon-100')).not.toBeInTheDocument();
  240. });
  241. it('hides both buttons when icon_url is not set', async () => {
  242. server.use(
  243. http.get('/api/v1/auth/oidc/providers/all', () =>
  244. HttpResponse.json([{ ...mockProviders[0], id: 101, icon_url: null, has_icon: false }])
  245. )
  246. );
  247. render(<OIDCProviderSettings />);
  248. await waitFor(() => {
  249. expect(screen.getByText('TestIdP')).toBeInTheDocument();
  250. });
  251. expect(screen.queryByTestId('refresh-icon-101')).not.toBeInTheDocument();
  252. expect(screen.queryByTestId('remove-icon-101')).not.toBeInTheDocument();
  253. });
  254. it('swaps in Globe fallback when icon image fails to load', async () => {
  255. // I3 (#1333 review): admin preview must show a meaningful fallback
  256. // instead of an unexplained gap (display: none) when the proxy
  257. // endpoint returns 404 (e.g. race with DELETE /icon).
  258. server.use(
  259. http.get('/api/v1/auth/oidc/providers/all', () =>
  260. HttpResponse.json([
  261. { ...mockProviders[0], id: 102, icon_url: 'https://idp.example.com/i.png', has_icon: true },
  262. ])
  263. )
  264. );
  265. render(<OIDCProviderSettings />);
  266. const img = (await screen.findByAltText('TestIdP')) as HTMLImageElement;
  267. fireEvent.error(img);
  268. // After error: <img> removed, Globe-fallback rendered. Confirm by
  269. // asserting the alt text is gone and the Globe SVG is present.
  270. await waitFor(() => {
  271. expect(screen.queryByAltText('TestIdP')).not.toBeInTheDocument();
  272. });
  273. });
  274. });
  275. });