/** * Tests for OIDCProviderSettings — focused on the auto_link / require_email_verified * toggle interaction (SEC-1/SEC-6 UI enforcement). */ import { describe, it, expect, beforeEach } from 'vitest'; import { screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { OIDCProviderSettings } from '../../components/OIDCProviderSettings'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; const mockProviders = [ { id: 1, name: 'TestIdP', issuer_url: 'https://idp.example.com', client_id: 'test-client', scopes: 'openid email profile', is_enabled: true, auto_create_users: false, auto_link_existing_accounts: false, email_claim: 'email', require_email_verified: true, icon_url: null, has_icon: false, default_group_id: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ]; beforeEach(() => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json(mockProviders)) ); }); describe('OIDCProviderSettings', () => { describe('ProviderForm — require_email_verified description logic', () => { it('shows standard description when require_email_verified is on and auto_link is off', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { // Default state: require_email_verified=true, auto_link=false → standard description expect( screen.getByText(/only.*accept.*email.*verified/i) ).toBeInTheDocument(); }); }); it('shows "Disable auto-link first" description when auto_link is enabled', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument(); }); // Find the Auto Link switch by aria-label or by position const switches = screen.getAllByRole('switch'); // Switches order in form: Enabled, AutoCreate, AutoLink, RequireEmailVerified // AutoLink is the 3rd switch (index 2) const autoLinkSwitch = switches[2]; await user.click(autoLinkSwitch); await waitFor(() => { expect( screen.getByText(/disable auto.?link first/i) ).toBeInTheDocument(); }); }); it('shows warning text when require_email_verified is toggled off', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument(); }); // RequireEmailVerified is the 4th switch (index 3) const switches = screen.getAllByRole('switch'); const reqEvSwitch = switches[3]; await user.click(reqEvSwitch); await waitFor(() => { expect( screen.getByText(/warning.*accept.*without.*verif/i) ).toBeInTheDocument(); }); }); it('shows security warning when auto_link is enabled with a custom email claim', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument(); }); // Enable auto_link (switch index 2) const autoLinkSwitch = screen.getAllByRole('switch')[2]; await user.click(autoLinkSwitch); // Change email claim to a custom value via fireEvent to bypass the onChange fallback const emailClaimInput = screen.getByPlaceholderText('email'); fireEvent.change(emailClaimInput, { target: { value: 'preferred_username' } }); await waitFor(() => { expect(screen.getByText(/tenant-administered/i)).toBeInTheDocument(); }); }); }); describe('Provider info view', () => { it('renders email_claim and require_email_verified fields in provider details', async () => { render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); // The provider card shows field labels in the details section expect(screen.getByText(/Email Claim/i)).toBeInTheDocument(); expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument(); }); it('renders Default Group label in provider details', async () => { render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); expect(screen.getByText(/Default Group/i)).toBeInTheDocument(); }); it('shows Viewers fallback label when default_group_id is null', async () => { render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); // null default_group_id should display the Viewers fallback text expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument(); }); it('shows group name when default_group_id matches a known group', async () => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([{ ...mockProviders[0], default_group_id: 2 }]) ) ); render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); // default_group_id=2 matches Operators in the global MSW mock expect(screen.getByText('Operators')).toBeInTheDocument(); }); }); describe('ProviderForm — default group dropdown', () => { it('renders a Default Group select in the create form', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { expect(screen.getByText(/Default Group/i)).toBeInTheDocument(); }); // Dropdown should render with Viewers fallback option const select = screen.getByRole('combobox'); expect(select).toBeInTheDocument(); expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument(); }); it('populates Default Group dropdown with groups from API', async () => { server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([]))); render(); await waitFor(() => { expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument(); }); await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]); await waitFor(() => { // Global MSW mock returns Administrators, Operators, Viewers const options = screen.getAllByRole('option'); const optionTexts = options.map((o) => o.textContent); expect(optionTexts).toContain('Operators'); expect(optionTexts).toContain('Administrators'); }); }); }); // #1333: icon proxy — preview uses the backend proxy URL (never icon_url // directly) and the admin gets explicit Refresh / Remove buttons. describe('Icon proxy (#1333)', () => { it('renders icon preview via the backend proxy URL when has_icon is true', async () => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([ { ...mockProviders[0], id: 42, icon_url: 'https://idp.example.com/icon.png', has_icon: true, }, ]) ) ); render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); const img = screen.getByAltText('TestIdP') as HTMLImageElement; expect(img.getAttribute('src')).toBe('/api/v1/auth/oidc/providers/42/icon'); }); it('exposes Refresh and Remove buttons when has_icon is true', async () => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([ { ...mockProviders[0], id: 99, icon_url: 'https://idp.example.com/i.png', has_icon: true }, ]) ) ); render(); await waitFor(() => { expect(screen.getByTestId('refresh-icon-99')).toBeInTheDocument(); }); expect(screen.getByTestId('remove-icon-99')).toBeInTheDocument(); }); it('hides Remove button when has_icon is false', async () => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([ // icon_url set but no cached bytes → Refresh visible, Remove hidden. { ...mockProviders[0], id: 100, icon_url: 'https://idp.example.com/i.png', has_icon: false }, ]) ) ); render(); await waitFor(() => { expect(screen.getByTestId('refresh-icon-100')).toBeInTheDocument(); }); expect(screen.queryByTestId('remove-icon-100')).not.toBeInTheDocument(); }); it('hides both buttons when icon_url is not set', async () => { server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([{ ...mockProviders[0], id: 101, icon_url: null, has_icon: false }]) ) ); render(); await waitFor(() => { expect(screen.getByText('TestIdP')).toBeInTheDocument(); }); expect(screen.queryByTestId('refresh-icon-101')).not.toBeInTheDocument(); expect(screen.queryByTestId('remove-icon-101')).not.toBeInTheDocument(); }); it('swaps in Globe fallback when icon image fails to load', async () => { // I3 (#1333 review): admin preview must show a meaningful fallback // instead of an unexplained gap (display: none) when the proxy // endpoint returns 404 (e.g. race with DELETE /icon). server.use( http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([ { ...mockProviders[0], id: 102, icon_url: 'https://idp.example.com/i.png', has_icon: true }, ]) ) ); render(); const img = (await screen.findByAltText('TestIdP')) as HTMLImageElement; fireEvent.error(img); // After error: removed, Globe-fallback rendered. Confirm by // asserting the alt text is gone and the Globe SVG is present. await waitFor(() => { expect(screen.queryByAltText('TestIdP')).not.toBeInTheDocument(); }); }); }); });