Files
TREK/client/src/pages/admin/AdminSettingsTab.test.tsx
T
Maurice 94ac7673a5 feat(settings): one look for every user and admin settings page
The settings pages had grown into a mix of styles: folds, dialogs behind
rows, full-width rows next to boxed ones, pills for every figure and buttons
in four sizes. Every page now uses the same kit.

- Cards keep their tinted head band; every part of a card is a rounded block
  with its own small head (SettingsBlock), and rows sit in rounded boxes.
- Nothing folds and nothing hides behind a dialog: the MCP config, OAuth
  scopes, mail how-to, AI privacy note and addon sub-options are all shown.
- Each page is split into two columns balanced by length (SettingsColumns).
- Long explanations moved into an info mark next to the label (HintMark).
- One button size everywhere (text-control); tab names are one word.
- Admin: the old Settings tab is split into Security and Services
  (?tab=settings still opens Security, and the phone keeps its single
  Settings section for both); the Integration group is gone, live flights
  and mail import sit next to the addons they configure.
- Addons: one card per type with rows, no search, filter or fold.
- Quota, TREK API facts, mail import status and Google's daily limit are
  compact lines and strips instead of tiles and pills.
- Language is a select; routing has its own card under the defaults;
  storage saves from the foot of its categories card.
- Unused keys, FoldSummary and the page-width wrapper are removed.
2026-11-13 23:18:28 +01:00

657 lines
29 KiB
TypeScript

// FE-ADMSET-001 to FE-ADMSET-044
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import { buildAdminHook, type AdminHook } from '../../../tests/helpers/mobileAdmin';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n';
import type { OidcConfig } from './adminModel';
import AdminSettingsTab from './AdminSettingsTab';
type Spy = ReturnType<typeof vi.fn>;
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
/** The three tabs the settings are split into (security, services, features), on one page. */
function AllParts({ admin, t }: { admin: AdminHook; t: ReturnType<typeof useTranslation>['t'] }) {
return (
<>
<AdminSettingsTab admin={admin} t={t} part="security" />
<AdminSettingsTab admin={admin} t={t} part="services" />
<AdminSettingsTab admin={admin} t={t} part="features" />
</>
);
}
function Harness({ admin }: { admin: AdminHook }) {
const { t } = useTranslation();
return <AllParts admin={admin} t={t} />;
}
function renderTab(overrides: Record<string, unknown> = {}) {
const admin = buildAdminHook(overrides);
render(<Harness admin={admin} />);
return admin as unknown as Spies;
}
/** The card (a settingsKit SettingsCard, a <section>) whose <h2> matches the given heading text. */
/** The card with the TREK services, the providers and the search log, apart from the keys. */
const SERVICES = 'Place search and routing';
function card(heading: string | RegExp): HTMLElement {
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('section')!;
}
/**
* The key input in the block whose show/hide toggle carries the given name. The
* toggle is the labelled element and the input its sibling, so this still finds
* the right field when the card gains a block, where an index would not.
*/
function keyInput(name: string | RegExp): HTMLInputElement {
return within(card('API Keys')).getByLabelText(name).parentElement!.querySelector('input')!;
}
/** The settingsKit row (label and hint left, control right) a label belongs to. */
function rowOf(el: HTMLElement): HTMLElement | null {
return el.tagName === 'LABEL' ? el.closest<HTMLElement>('.flex-wrap') : null;
}
/** The toggle button in the row belonging to a label. */
function toggleFor(label: string): HTMLElement {
const row = screen.getAllByText(label).map(rowOf);
const found = row.find(Boolean)!;
return within(found).getByRole('button');
}
const EMPTY_OIDC: OidcConfig = {
issuer: '',
client_id: '',
client_secret: '',
client_secret_set: false,
display_name: '',
discovery_url: '',
};
/**
* setOidcConfig gets an updater that reads e.target.value lazily, so a spy call
* can no longer be replayed once React has re-rendered the controlled input.
* This harness keeps the config in real state instead.
*/
function OidcHarness() {
const { t } = useTranslation();
const [oidcConfig, setOidcConfig] = React.useState<OidcConfig>(EMPTY_OIDC);
const admin = buildAdminHook({ oidcConfig, setOidcConfig });
return <AllParts admin={admin} t={t} />;
}
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AdminSettingsTab', () => {
it('FE-ADMSET-001: renders every settings card', () => {
renderTab();
expect(screen.getByRole('heading', { name: 'Authentication Methods' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Passkey login' })).toBeInTheDocument();
// Requiring two-factor is a switch of the sign-in card now, not a card of its own.
expect(screen.getByText(/require two-factor/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'API Keys' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /single sign-on/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /danger zone/i })).toBeInTheDocument();
});
it('FE-ADMSET-002: hides the env-override hint unless OIDC_ONLY is set', () => {
renderTab();
expect(screen.queryByText(/OIDC_ONLY environment variable/i)).not.toBeInTheDocument();
expect(toggleFor('Password Login')).not.toBeDisabled();
});
it('FE-ADMSET-003: shows the env-override hint and disables the password toggles', () => {
renderTab({ envOverrideOidcOnly: true });
expect(screen.getByText(/OIDC_ONLY environment variable/i)).toBeInTheDocument();
expect(toggleFor('Password Login')).toBeDisabled();
expect(toggleFor('Password Registration')).toBeDisabled();
});
it('FE-ADMSET-004: password login toggle sends the inverted value', () => {
const admin = renderTab({ passwordLogin: true, oidcLogin: true });
fireEvent.click(toggleFor('Password Login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('password_login', false, admin.setPasswordLogin);
});
it('FE-ADMSET-005: password login toggle is locked out when it is the last method', () => {
renderTab({ passwordLogin: false, oidcLogin: false });
const toggle = toggleFor('Password Login');
expect(toggle).toBeDisabled();
expect(toggle).toHaveAttribute('title', 'At least one login method must remain enabled');
});
it('FE-ADMSET-006: password registration toggle sends the inverted value', () => {
const admin = renderTab({ passwordRegistration: false });
fireEvent.click(toggleFor('Password Registration'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
'password_registration',
true,
admin.setPasswordRegistration
);
});
it('FE-ADMSET-007: SSO rows are hidden until OIDC is configured', () => {
renderTab({ oidcConfigured: false });
expect(screen.queryByText('SSO Login')).not.toBeInTheDocument();
expect(screen.queryByText('SSO Auto-Provisioning')).not.toBeInTheDocument();
expect(screen.queryByText('Go straight to SSO')).not.toBeInTheDocument();
});
it('FE-ADMSET-008: SSO login toggle sends the inverted value', () => {
const admin = renderTab({ oidcConfigured: true, oidcLogin: true, passwordLogin: true });
fireEvent.click(toggleFor('SSO Login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_login', false, admin.setOidcLogin);
});
it('FE-ADMSET-009: SSO login toggle is disabled when password login is already off', () => {
renderTab({ oidcConfigured: true, oidcLogin: true, passwordLogin: false });
expect(toggleFor('SSO Login')).toBeDisabled();
});
it('FE-ADMSET-010: SSO auto-provisioning toggle sends the inverted value', () => {
const admin = renderTab({ oidcConfigured: true, oidcRegistration: false });
fireEvent.click(toggleFor('SSO Auto-Provisioning'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_registration', true, admin.setOidcRegistration);
});
it('FE-ADMSET-044: the SSO auto redirect toggle sends the inverted value (#2925)', () => {
const admin = renderTab({ oidcConfigured: true, oidcAutoRedirect: true });
fireEvent.click(toggleFor('Go straight to SSO'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_auto_redirect', false, admin.setOidcAutoRedirect);
});
it('FE-ADMSET-011: passkey toggle sends the inverted value', () => {
const admin = renderTab({ passkeyLogin: false });
fireEvent.click(toggleFor('Enable passkey login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('passkey_login', true, admin.setPasskeyLogin);
});
it('FE-ADMSET-012: warns only when passkey login is on but no RP ID resolves', () => {
const { unmount } = render(<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: false })} />);
expect(screen.getByText(/No WebAuthn domain resolves/)).toBeInTheDocument();
unmount();
render(<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: true })} />);
expect(screen.queryByText(/No WebAuthn domain resolves/)).not.toBeInTheDocument();
});
it('FE-ADMSET-013: webauthn RP ID and origins inputs write through their setters', () => {
const admin = renderTab({ webauthnRpId: 'old.example', webauthnOrigins: '' });
expect(screen.getByPlaceholderText('trek.example.org')).toHaveValue('old.example');
fireEvent.change(screen.getByPlaceholderText('trek.example.org'), { target: { value: 'trek.test' } });
expect(admin.setWebauthnRpId).toHaveBeenCalledWith('trek.test');
fireEvent.change(screen.getByPlaceholderText('https://trek.example.org'), {
target: { value: 'https://trek.test' },
});
expect(admin.setWebauthnOrigins).toHaveBeenCalledWith('https://trek.test');
});
it('FE-ADMSET-014: passkey save button calls handleSaveWebauthn', () => {
const admin = renderTab();
fireEvent.click(within(card('Passkey login')).getByRole('button', { name: /save/i }));
expect(admin.handleSaveWebauthn).toHaveBeenCalledTimes(1);
});
it('FE-ADMSET-015: the passkey save button is disabled while saving', () => {
renderTab({ savingWebauthn: true });
expect(within(card('Passkey login')).getByRole('button', { name: /save/i })).toBeDisabled();
});
it('FE-ADMSET-016: require-MFA toggle flips the current value', () => {
const admin = renderTab({ requireMfa: false });
fireEvent.click(within(card(/require two-factor/i)).getByRole('button'));
expect(admin.handleToggleRequireMfa).toHaveBeenCalledWith(true);
});
it('FE-ADMSET-017: file types input writes through its setter', () => {
const admin = renderTab({ allowedFileTypes: 'jpg,png' });
fireEvent.change(screen.getByDisplayValue('jpg,png'), { target: { value: 'jpg,png,pdf' } });
expect(admin.setAllowedFileTypes).toHaveBeenCalledWith('jpg,png,pdf');
});
it('FE-ADMSET-018: saving file types PUTs allowed_file_types and toasts', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
const admin = renderTab({ allowedFileTypes: 'jpg,png' });
fireEvent.click(within(card('Allowed File Types')).getByRole('button', { name: /save/i }));
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('File type settings saved'));
expect(body).toEqual({ allowed_file_types: 'jpg,png' });
expect(admin.setSavingFileTypes).toHaveBeenNthCalledWith(1, true);
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
});
it('FE-ADMSET-019: a failing file-type save shows the generic error toast', async () => {
server.use(http.put('/api/auth/app-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
const admin = renderTab();
fireEvent.click(within(card('Allowed File Types')).getByRole('button', { name: /save/i }));
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
expect(admin.toast.success).not.toHaveBeenCalled();
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
});
it('FE-ADMSET-020: maps key input and its visibility toggle', () => {
const admin = renderTab({ mapsKey: 'abc' });
const mapsInput = within(card('API Keys')).getAllByPlaceholderText('Enter key...')[0];
expect(mapsInput).toHaveAttribute('type', 'password');
fireEvent.change(mapsInput, { target: { value: 'abcd' } });
expect(admin.setMapsKey).toHaveBeenCalledWith('abcd');
fireEvent.click(mapsInput.parentElement!.querySelector('button')!);
expect(admin.toggleKey).toHaveBeenCalledWith('maps');
});
// Selected by accessible name rather than by position: the API-keys card has
// grown a field before now (Amap), and an index-based lookup silently starts
// asserting about a different input when it does.
it('FE-ADMSET-021: showKeys switches every key input to plain text', () => {
renderTab({ showKeys: { maps: true, unsplash: true, amap: true } });
expect(keyInput('Google Maps API Key')).toHaveAttribute('type', 'text');
expect(keyInput('Unsplash API Key')).toHaveAttribute('type', 'text');
expect(keyInput(/Amap/)).toHaveAttribute('type', 'text');
});
it('FE-ADMSET-022: unsplash key input and its visibility toggle', () => {
const admin = renderTab({ unsplashKey: '' });
const unsplashInput = keyInput('Unsplash API Key');
fireEvent.change(unsplashInput, { target: { value: 'unsplash-key' } });
expect(admin.setUnsplashKey).toHaveBeenCalledWith('unsplash-key');
fireEvent.click(unsplashInput.parentElement!.querySelector('button')!);
expect(admin.toggleKey).toHaveBeenCalledWith('unsplash');
});
it('FE-ADMSET-022b: amap key input and its visibility toggle', () => {
const admin = renderTab({ amapKey: '' });
const amapInput = keyInput(/Amap/);
fireEvent.change(amapInput, { target: { value: 'amap-key' } });
expect(admin.setAmapKey).toHaveBeenCalledWith('amap-key');
fireEvent.click(amapInput.parentElement!.querySelector('button')!);
expect(admin.toggleKey).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022c: warns when the selected provider has no key behind it', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing, which is quiet enough to be mistaken for the provider working.
renderTab({ placesProvider: 'amap', hasAmapKey: false });
expect(
within(card(SERVICES)).getByText(/TREK index and OpenStreetMap alone/i),
).toBeInTheDocument();
});
it('FE-ADMSET-022d: saving the provider goes through updateAppSettings, not the keys', () => {
const admin = renderTab({ placesProvider: 'auto' });
// A CustomSelect: the trigger shows the current choice, the menu is portaled.
fireEvent.click(within(card(SERVICES)).getByRole('button', { name: 'Automatic' }));
fireEvent.click(screen.getByRole('button', { name: 'Amap (高德地图)' }));
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022e: a managed install hides place search, routing and the keys', () => {
// All of it is the operator's there (server managed.ts locks places_provider);
// Share place details has a tab of its own on AdminPage.
renderTab({ managed: true, placesProvider: 'auto' });
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: SERVICES })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
});
it('FE-ADMSET-022f: a managed install keeps the file types card on the services tab', () => {
// Place search and the keys are the operator's there; the file types stay the admin's.
renderTab({ managed: true });
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
renderTab({ mapsKey: '' });
expect(within(card('API Keys')).getByRole('button', { name: /^test$/i })).toBeDisabled();
});
it('FE-ADMSET-043: a key the environment sets is read-only and names its variable (#1881)', () => {
const admin = renderTab({
keyInputProps: (field: string) =>
field === 'maps' ? { disabled: true, placeholder: 'Set via PLACES_API_KEY' } : { disabled: false, placeholder: 'Enter key...' },
mapsKeyTestable: true,
});
expect(keyInput('Google Maps API Key')).toBeDisabled();
expect(keyInput('Google Maps API Key')).toHaveAttribute('placeholder', 'Set via PLACES_API_KEY');
expect(keyInput(/unsplash/i)).toBeEnabled();
// The empty field still tests: the probe uses the key a search resolves to.
fireEvent.click(within(card('API Keys')).getByRole('button', { name: /^test$/i }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-024: the maps Test button validates the maps key', () => {
const admin = renderTab({ mapsKey: 'AIza-test' });
fireEvent.click(within(card('API Keys')).getByRole('button', { name: /^test$/i }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-025: shows a spinner while the maps key is validating', () => {
renderTab({ mapsKey: 'k', validating: { maps: true } });
const testBtn = within(card('API Keys')).getByRole('button', { name: /^test$/i });
expect(testBtn).toBeDisabled();
expect(testBtn.querySelector('svg.animate-spin')).toBeInTheDocument();
});
it('FE-ADMSET-026: renders the validation result for the maps key', () => {
const { unmount } = render(<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: true } })} />);
expect(screen.getByText('Connected')).toBeInTheDocument();
unmount();
render(<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: false } })} />);
expect(screen.getByText('Invalid')).toBeInTheDocument();
expect(screen.queryByText('Connected')).not.toBeInTheDocument();
});
it('FE-ADMSET-027: places photos toggle updates state, store and API', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/places-photos', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ enabled: true });
})
);
const admin = renderTab({ placesPhotosEnabled: false });
fireEvent.click(toggleFor('Place Photos'));
expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true);
expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true);
await waitFor(() => expect(body).toEqual({ enabled: true }));
});
it('FE-ADMSET-028: a failing places-photos update rolls the toggle back', async () => {
server.use(http.put('/api/admin/places-photos', () => HttpResponse.json({}, { status: 500 })));
const admin = renderTab({ placesPhotosEnabled: false });
fireEvent.click(toggleFor('Place Photos'));
await waitFor(() => expect(admin.setPlacesPhotosEnabledState).toHaveBeenLastCalledWith(false));
expect(admin.setPlacesPhotosEnabled).toHaveBeenLastCalledWith(false);
});
it('FE-ADMSET-029: places autocomplete toggle updates state and rolls back on failure', async () => {
server.use(http.put('/api/admin/places-autocomplete', () => HttpResponse.json({}, { status: 500 })));
const admin = renderTab({ placesAutocompleteEnabled: true });
fireEvent.click(toggleFor('Place Autocomplete'));
expect(admin.setPlacesAutocompleteEnabledState).toHaveBeenNthCalledWith(1, false);
await waitFor(() => expect(admin.setPlacesAutocompleteEnabledState).toHaveBeenLastCalledWith(true));
expect(admin.setPlacesAutocompleteEnabled).toHaveBeenLastCalledWith(true);
});
it('FE-ADMSET-030: places details toggle updates state and rolls back on failure', async () => {
server.use(http.put('/api/admin/places-details', () => HttpResponse.json({}, { status: 500 })));
const admin = renderTab({ placesDetailsEnabled: true });
fireEvent.click(toggleFor('Place Details'));
expect(admin.setPlacesDetailsEnabledState).toHaveBeenNthCalledWith(1, false);
await waitFor(() => expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true));
expect(admin.setPlacesDetailsEnabled).toHaveBeenLastCalledWith(true);
});
it('FE-ADMSET-030b: places enrichment toggle updates state and rolls back on failure', async () => {
server.use(http.put('/api/admin/places-enrich', () => HttpResponse.json({}, { status: 500 })));
const admin = renderTab({ placesEnrichEnabled: true });
fireEvent.click(toggleFor('Place Enrichment'));
expect(admin.setPlacesEnrichEnabledState).toHaveBeenNthCalledWith(1, false);
await waitFor(() => expect(admin.setPlacesEnrichEnabledState).toHaveBeenLastCalledWith(true));
expect(admin.setPlacesEnrichEnabled).toHaveBeenLastCalledWith(true);
});
it('FE-ADMSET-030c: the four places toggles expose their label to screen readers', () => {
// They moved from hand-rolled buttons to ToggleSwitch, which is what carries
// the aria-label and aria-pressed the bare markup never had.
renderTab({ placesPhotosEnabled: true, placesEnrichEnabled: false });
expect(toggleFor('Place Photos')).toHaveAttribute('aria-pressed', 'true');
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-pressed', 'false');
expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment');
});
it('FE-ADMSET-030d: the Google-only switch sits with the Google options, says what it needs without a key, and toggles through the hook', () => {
const admin = renderTab({ placesGoogleOnly: false, hasMapsKey: false });
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggleFor('Search with Google only'));
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
});
it('FE-ADMSET-030e: with a key the Google-only row explains what it changes', () => {
renderTab({ placesGoogleOnly: true, hasMapsKey: true });
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ADMSET-030f: with a key but Amap or OpenStreetMap picked, the Google-only row says the switch is idle', () => {
// A key alone does not put Google behind the search; the server ignores the
// switch under another provider, and the row must not promise otherwise.
renderTab({ placesGoogleOnly: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex-wrap')!.querySelector('p')!;
expect(hint.textContent).not.toMatch(/Every search and every suggestion goes to Google Places/);
expect(hint.textContent).not.toMatch(/Needs a Google Maps API key/);
expect(hint.textContent).toMatch(/provider/i);
});
it('FE-ADMSET-031: the place search card leads with the TREK index and no weather panel', () => {
renderTab();
const keys = card(SERVICES);
expect(within(keys).getByRole('img', { name: 'TREK Places API' })).toBeInTheDocument();
expect(within(keys).getByText('Recommended default')).toBeInTheDocument();
// Weather needs no key, so it left a card about keys. The panel itself lives
// on in the mobile section, where FE-MOB-ASET-019 asserts it.
expect(screen.queryByText('Weather Data')).not.toBeInTheDocument();
});
it('FE-ADMSET-032: the API keys save button calls handleSaveApiKeys', () => {
const admin = renderTab();
fireEvent.click(within(card('API Keys')).getByRole('button', { name: /^save$/i }));
expect(admin.handleSaveApiKeys).toHaveBeenCalledTimes(1);
});
it('FE-ADMSET-033: display name, issuer and discovery URL update the OIDC config', () => {
render(<OidcHarness />);
const oidc = card(/single sign-on/i);
fireEvent.change(within(oidc).getByPlaceholderText('e.g. Google, Authentik, Keycloak'), {
target: { value: 'Authentik' },
});
fireEvent.change(within(oidc).getByPlaceholderText('https://accounts.google.com'), {
target: { value: 'https://auth.example.com' },
});
fireEvent.change(within(oidc).getByPlaceholderText(/openid-configuration$/), {
target: { value: 'https://auth.example.com/.well-known/openid-configuration' },
});
expect(screen.getByDisplayValue('Authentik')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://auth.example.com')).toBeInTheDocument();
expect(
screen.getByDisplayValue('https://auth.example.com/.well-known/openid-configuration')
).toBeInTheDocument();
});
it('FE-ADMSET-034: client id and client secret update the OIDC config', () => {
render(<OidcHarness />);
const oidc = card(/single sign-on/i);
const clientId = within(oidc).getByText('Client ID').parentElement!.querySelector('input')!;
fireEvent.change(clientId, { target: { value: 'trek-client' } });
const clientSecret = within(oidc).getByText('Client Secret').parentElement!.querySelector('input')!;
fireEvent.change(clientSecret, { target: { value: 's3cr3t' } });
expect(clientId).toHaveValue('trek-client');
expect(clientSecret).toHaveValue('s3cr3t');
});
it('FE-ADMSET-035: masks the client secret placeholder once one is stored', () => {
renderTab({ oidcConfig: { ...EMPTY_OIDC, client_secret_set: true } });
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
});
it('FE-ADMSET-036: saving OIDC omits an empty secret and toasts on success', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
const admin = renderTab({
oidcConfig: { ...EMPTY_OIDC, issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' },
});
fireEvent.click(within(card(/single sign-on/i)).getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('OIDC configuration saved'));
expect(body).toEqual({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP', discovery_url: '' });
});
it('FE-ADMSET-037: saving OIDC includes a freshly typed secret', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
renderTab({
oidcConfig: { ...EMPTY_OIDC, issuer: 'https://idp', client_id: 'cid', client_secret: 'topsecret' },
});
fireEvent.click(within(card(/single sign-on/i)).getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(body).toMatchObject({ client_secret: 'topsecret' }));
});
it('FE-ADMSET-038: a failing OIDC save surfaces the server error message', async () => {
server.use(http.put('/api/admin/oidc', () => HttpResponse.json({ error: 'Issuer unreachable' }, { status: 400 })));
const admin = renderTab();
fireEvent.click(within(card(/single sign-on/i)).getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Issuer unreachable'));
expect(admin.setSavingOidc).toHaveBeenNthCalledWith(1, true);
expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false);
});
it('FE-ADMSET-039: the OIDC save button is disabled while saving', () => {
renderTab({ savingOidc: true });
expect(within(card(/single sign-on/i)).getByRole('button', { name: /^save$/i })).toBeDisabled();
});
it('FE-ADMSET-040: the danger zone opens the rotate-JWT modal', () => {
const admin = renderTab();
fireEvent.click(screen.getByRole('button', { name: /^rotate$/i }));
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
});
it('FE-ADMSET-041: the index has no switch, because turning it off is a deployment decision', () => {
// It decides whether a search leaves the instance at all, so it belongs in
// the compose file rather than in a browser: TREK_PLACES_ENABLED=false.
// A card that offered the choice here also had to explain what answers
// instead, which is a paragraph about configuration inside a card about a
// data source.
renderTab();
expect(screen.queryByRole('button', { name: /trek place index/i })).not.toBeInTheDocument();
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
});
it('FE-ADMSET-042: a failing transit provider save rolls back and says why', async () => {
// CustomSelect renders a plain button named after the selected option, so the
// trigger is found through its own block, and the menu entry is the button that
// is not the trigger.
server.use(http.put('/api/admin/transit-provider', () => HttpResponse.json({ error: 'Settings are read-only' }, { status: 500 })));
const admin = renderTab({
transitProvider: 'transitous',
setTransitProviderState: vi.fn(),
transitGoogleKeySource: 'instance',
setTransitGoogleKeySource: vi.fn(),
});
const block = screen.getByText('Transit Provider').closest<HTMLElement>('section')!;
fireEvent.click(within(block).getByRole('button'));
fireEvent.click(screen.getByText('Google'));
expect(admin.setTransitProviderState).toHaveBeenNthCalledWith(1, 'google');
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Settings are read-only'));
expect(admin.setTransitProviderState).toHaveBeenLastCalledWith('transitous');
expect(admin.setTransitGoogleKeySource).not.toHaveBeenCalled();
});
});