mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
* feat(auth): proxy OIDC provider icons server-side (#1333) Strict img-src CSP blocked external OIDC icon hosts on the login page. Loosening CSP was rejected via the MakerWorld precedent, so icons are proxied: admin sets icon_url, backend fetches and caches the bytes in a deferred BLOB column, the SPA renders from a same-origin /api/v1/auth/oidc/providers/{id}/icon endpoint.
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
|
* 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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
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(<OIDCProviderSettings />);
|
|
|
|
const img = (await screen.findByAltText('TestIdP')) as HTMLImageElement;
|
|
fireEvent.error(img);
|
|
// After error: <img> 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();
|
|
});
|
|
});
|
|
});
|
|
});
|