Files
TREK/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx
T
Maurice 76f45512d1 feat(admin): the phone's API-keys card, rebuilt like the desktop tab
The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.

So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.

The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.

Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
2026-09-13 21:07:55 +02:00

458 lines
19 KiB
TypeScript

// FE-MOB-ASET-001 to FE-MOB-ASET-023
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import { buildAdminHook, type AdminHook } from '../../../helpers/mobileAdmin';
import { useTranslation } from '../../../../src/i18n';
import MAdminSettingsSection from '../../../../src/mobile/screens/admin/MAdminSettingsSection';
function Harness({ admin }: { admin: AdminHook }) {
const { t } = useTranslation();
return <MAdminSettingsSection admin={admin} t={t} />;
}
type Spy = ReturnType<typeof vi.fn>;
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
function renderSettings(overrides: Record<string, unknown> = {}) {
const admin = buildAdminHook(overrides);
render(<Harness admin={admin} />);
return admin as unknown as Spies;
}
function toggle(label: string): HTMLElement {
return screen.getByRole('switch', { name: label });
}
/** Client ID / Client Secret are the only inputs of their type without a placeholder. */
function unlabelledInput(type: 'text' | 'password'): HTMLInputElement {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>(`input[type="${type}"]`));
const match = inputs.find((i) => !i.placeholder);
if (!match) throw new Error(`no ${type} input without a placeholder`);
return match;
}
/**
* setOidcConfig receives an updater that reads e.target.value lazily, so the
* spy fixture can't be replayed after the event — this harness holds the config
* in real state instead.
*/
function OidcHarness() {
const { t } = useTranslation();
const [oidcConfig, setOidcConfig] = React.useState({
issuer: '',
client_id: '',
client_secret: '',
client_secret_set: false,
display_name: '',
discovery_url: '',
});
const admin = buildAdminHook({ oidcConfig, setOidcConfig });
return <MAdminSettingsSection admin={admin} t={t} />;
}
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdminSettingsSection', () => {
it('FE-MOB-ASET-001: renders every card of the settings tab', () => {
renderSettings();
expect(screen.getByText('Authentication Methods')).toBeInTheDocument();
expect(screen.getByText('Passkey login')).toBeInTheDocument();
expect(screen.getByText('Require two-factor authentication (2FA)')).toBeInTheDocument();
// Card head and field label share the copy
expect(screen.getAllByText('Allowed File Types')).toHaveLength(2);
expect(screen.getByText('API Keys')).toBeInTheDocument();
expect(screen.getByText('Single Sign-On (OIDC)')).toBeInTheDocument();
expect(screen.getByText('Danger Zone')).toBeInTheDocument();
});
it('FE-MOB-ASET-002: the OIDC toggles only appear once OIDC is configured', () => {
renderSettings({ oidcConfigured: false });
expect(screen.queryByRole('switch', { name: 'SSO Login' })).not.toBeInTheDocument();
expect(screen.queryByRole('switch', { name: 'SSO Auto-Provisioning' })).not.toBeInTheDocument();
});
it('FE-MOB-ASET-003: an env override disables the password toggles and shows the hint', () => {
renderSettings({ envOverrideOidcOnly: true, oidcConfigured: true });
expect(
screen.getByText(
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
),
).toBeInTheDocument();
expect(toggle('Password Login')).toBeDisabled();
expect(toggle('Password Registration')).toBeDisabled();
expect(toggle('SSO Login')).not.toBeDisabled();
});
it('FE-MOB-ASET-004: the last remaining login method cannot be switched off', () => {
renderSettings({ passwordLogin: false, oidcLogin: false, oidcConfigured: true });
expect(toggle('Password Login')).toBeDisabled();
expect(toggle('SSO Login')).not.toBeDisabled();
});
it('FE-MOB-ASET-005: each auth toggle reports its own key and the new value', async () => {
const user = userEvent.setup();
const admin = renderSettings({ oidcConfigured: true, oidcLogin: true, oidcRegistration: false });
await user.click(toggle('Password Login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
'password_login', false, admin.setPasswordLogin,
);
await user.click(toggle('Password Registration'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
'password_registration', false, admin.setPasswordRegistration,
);
await user.click(toggle('SSO Login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_login', false, admin.setOidcLogin);
await user.click(toggle('SSO Auto-Provisioning'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith(
'oidc_registration', true, admin.setOidcRegistration,
);
await user.click(toggle('Enable passkey login'));
expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('passkey_login', true, admin.setPasskeyLogin);
});
it('FE-MOB-ASET-006: the require-MFA toggle calls its dedicated handler', async () => {
const user = userEvent.setup();
const admin = renderSettings({ requireMfa: false });
await user.click(toggle('Require two-factor authentication (2FA)'));
expect(admin.handleToggleRequireMfa).toHaveBeenCalledWith(true);
});
it('FE-MOB-ASET-007: the passkey warning shows only when enabled but unconfigured', () => {
const { unmount } = render(
<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: false })} />,
);
expect(
screen.getByText(
'No WebAuthn domain resolves for this deployment yet. Set APP_URL or the Relying Party ID below — passkeys stay hidden until then.',
),
).toBeInTheDocument();
unmount();
render(<Harness admin={buildAdminHook({ passkeyLogin: true, passkeyConfigured: true })} />);
expect(screen.queryByText(/No WebAuthn domain resolves/)).not.toBeInTheDocument();
});
it('FE-MOB-ASET-008: the WebAuthn fields render their values and save through the hook', async () => {
const user = userEvent.setup();
const admin = renderSettings({
webauthnRpId: 'trek.example.org',
webauthnOrigins: 'https://trek.example.org',
});
expect(screen.getByPlaceholderText('trek.example.org')).toHaveValue('trek.example.org');
expect(screen.getByPlaceholderText('https://trek.example.org')).toHaveValue('https://trek.example.org');
await user.click(screen.getAllByRole('button', { name: 'Save' })[0]);
expect(admin.handleSaveWebauthn).toHaveBeenCalled();
});
it('FE-MOB-ASET-009: editing the RP ID and origins goes through their setters', async () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.type(screen.getByPlaceholderText('trek.example.org'), 'a');
expect(admin.setWebauthnRpId).toHaveBeenCalledWith('a');
await user.type(screen.getByPlaceholderText('https://trek.example.org'), 'b');
expect(admin.setWebauthnOrigins).toHaveBeenCalledWith('b');
});
it('FE-MOB-ASET-010: saving the allowed file types PUTs them and reports success', async () => {
const user = userEvent.setup();
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
sent = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(sent);
}),
);
const admin = renderSettings({ allowedFileTypes: 'jpg,png' });
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('File type settings saved'));
expect(sent).toEqual({ allowed_file_types: 'jpg,png' });
expect(admin.setSavingFileTypes).toHaveBeenNthCalledWith(1, true);
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
});
it('FE-MOB-ASET-011: a failing file-types save toasts the generic error', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/auth/app-settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
const admin = renderSettings();
await user.click(screen.getAllByRole('button', { name: 'Save' })[1]);
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
});
it('FE-MOB-ASET-012: the file-types input writes back through its setter', async () => {
const user = userEvent.setup();
const admin = renderSettings({ allowedFileTypes: '' });
await user.type(screen.getByPlaceholderText('jpg,png,pdf,doc,docx,xls,xlsx,txt,csv'), 'p');
expect(admin.setAllowedFileTypes).toHaveBeenCalledWith('p');
});
it('FE-MOB-ASET-013: the maps key validate button is disabled without a key', () => {
renderSettings({ mapsKey: '' });
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
});
it('FE-MOB-ASET-014: validating a maps key calls the handler for that key type', async () => {
const user = userEvent.setup();
const admin = renderSettings({ mapsKey: 'AIza-test' });
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-MOB-ASET-015: the validation result renders as a valid or invalid line', () => {
const { unmount } = render(
<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: true } })} />,
);
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.queryByText('Invalid')).not.toBeInTheDocument();
unmount();
render(<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: false } })} />);
expect(screen.getByText('Invalid')).toBeInTheDocument();
});
it('FE-MOB-ASET-016: the API keys card saves through the hook', async () => {
const user = userEvent.setup();
const admin = renderSettings();
// By accessible name rather than by position: the card gained an Amap field
// between Maps and Unsplash, and an index quietly starts asserting about a
// different input when that happens.
await user.type(screen.getByLabelText('Google Maps API Key'), 'm');
expect(admin.setMapsKey).toHaveBeenCalledWith('m');
await user.type(screen.getByLabelText('Unsplash API Key'), 'u');
expect(admin.setUnsplashKey).toHaveBeenCalledWith('u');
await user.type(screen.getByLabelText(/Amap/), 'a');
expect(admin.setAmapKey).toHaveBeenCalledWith('a');
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
});
it('FE-MOB-ASET-016b: a managed install hides the keys but keeps the provider choice', () => {
// The operator owns the credentials; which of them answers place search is
// still the admin's call, the same split the desktop tab makes.
renderSettings({ managed: true });
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Amap/)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Unsplash API Key')).not.toBeInTheDocument();
// The native select became TREK's own picker: a button carrying the choice.
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
it('FE-MOB-ASET-016c: the missing-key notice follows app-config, not the fields', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing. Which of the two is true is app-config's answer: the key fields
// are empty on a managed install and on a key set by the environment.
const { unmount } = render(<Harness admin={buildAdminHook({ placesProvider: 'amap', hasAmapKey: false })} />);
expect(screen.getByText(/TREK index and OpenStreetMap alone/i)).toBeInTheDocument();
unmount();
renderSettings({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
expect(screen.queryByText(/TREK index and OpenStreetMap alone/i)).not.toBeInTheDocument();
});
it('FE-MOB-ASET-017: the Google Places toggles persist optimistically', async () => {
const user = userEvent.setup();
const seen: string[] = [];
server.use(
http.put('/api/admin/places-photos', () => {
seen.push('photos');
return HttpResponse.json({ enabled: true });
}),
http.put('/api/admin/places-autocomplete', () => {
seen.push('autocomplete');
return HttpResponse.json({ enabled: true });
}),
http.put('/api/admin/places-details', () => {
seen.push('details');
return HttpResponse.json({ enabled: true });
}),
);
const admin = renderSettings();
// They live under the Google key now, folded away by default: set once when a
// key is pasted, never touched again.
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
await user.click(toggle('Place Photos'));
await user.click(toggle('Place Autocomplete'));
await user.click(toggle('Place Details'));
await waitFor(() => expect(seen).toEqual(['photos', 'autocomplete', 'details']));
expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true);
expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true);
expect(admin.setPlacesAutocompleteEnabled).toHaveBeenCalledWith(true);
expect(admin.setPlacesDetailsEnabled).toHaveBeenCalledWith(true);
});
it('FE-MOB-ASET-018: a rejected Places update rolls the toggle back', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/admin/places-photos', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.put('/api/admin/places-autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
http.put('/api/admin/places-details', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
const admin = renderSettings({
placesPhotosEnabled: true,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
});
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
await user.click(toggle('Place Photos'));
await user.click(toggle('Place Autocomplete'));
await user.click(toggle('Place Details'));
await waitFor(() => expect(admin.setPlacesPhotosEnabledState).toHaveBeenLastCalledWith(true));
expect(admin.setPlacesPhotosEnabled).toHaveBeenLastCalledWith(true);
expect(admin.setPlacesAutocompleteEnabledState).toHaveBeenLastCalledWith(true);
expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true);
});
it('FE-MOB-ASET-019: the API-keys card is about keys, and weather needs none', () => {
// The Open-Meteo panel left this card, as it did on the desktop tab: it takes
// no key and has nothing to configure, so it had no business in a card about
// keys, where it had the loudest treatment of anything on it.
renderSettings();
expect(screen.queryByText('Weather Data')).not.toBeInTheDocument();
expect(screen.queryByText('10,000 requests / day')).not.toBeInTheDocument();
});
it('FE-MOB-ASET-020: the OIDC form renders its values and saves a payload without an empty secret', async () => {
const user = userEvent.setup();
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
sent = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(sent);
}),
);
const admin = renderSettings({
oidcConfig: {
issuer: 'https://idp.example.org',
client_id: 'trek',
client_secret: '',
client_secret_set: true,
display_name: 'Authentik',
discovery_url: 'https://idp.example.org/.well-known/openid-configuration',
},
});
expect(screen.getByDisplayValue('Authentik')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://idp.example.org')).toBeInTheDocument();
expect(screen.getByDisplayValue('trek')).toBeInTheDocument();
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: 'Save' })[3]);
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('OIDC configuration saved'));
expect(sent).toEqual({
issuer: 'https://idp.example.org',
client_id: 'trek',
display_name: 'Authentik',
discovery_url: 'https://idp.example.org/.well-known/openid-configuration',
});
expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false);
});
it('FE-MOB-ASET-021: a filled secret is sent and a server error message is surfaced', async () => {
const user = userEvent.setup();
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/admin/oidc', async ({ request }) => {
sent = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ error: 'Issuer unreachable' }, { status: 400 });
}),
);
const admin = renderSettings({
oidcConfig: {
issuer: 'https://idp.example.org',
client_id: 'trek',
client_secret: 's3cr3t',
client_secret_set: false,
display_name: 'Authentik',
discovery_url: '',
},
});
await user.click(screen.getAllByRole('button', { name: 'Save' })[3]);
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Issuer unreachable'));
expect(sent.client_secret).toBe('s3cr3t');
});
it('FE-MOB-ASET-022: every OIDC input patches only its own field', async () => {
const user = userEvent.setup();
render(<OidcHarness />);
await user.type(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak'), 'Authentik');
await user.type(screen.getByPlaceholderText('https://accounts.google.com'), 'https://idp');
await user.type(
screen.getByPlaceholderText(
'https://auth.example.com/application/o/trek/.well-known/openid-configuration',
),
'https://idp/disco',
);
await user.type(unlabelledInput('text'), 'trek');
await user.type(unlabelledInput('password'), 's3cr3t');
expect(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak')).toHaveValue('Authentik');
expect(screen.getByPlaceholderText('https://accounts.google.com')).toHaveValue('https://idp');
expect(
screen.getByPlaceholderText(
'https://auth.example.com/application/o/trek/.well-known/openid-configuration',
),
).toHaveValue('https://idp/disco');
expect(unlabelledInput('text')).toHaveValue('trek');
expect(unlabelledInput('password')).toHaveValue('s3cr3t');
});
it('FE-MOB-ASET-023: the danger zone opens the rotate-JWT confirm', async () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.click(screen.getByRole('button', { name: 'Rotate' }));
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true);
});
});