Files
TREK/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx
Maurice 8906b66bc8 feat(settings): set the Google Maps key through PLACES_API_KEY
The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
2026-09-24 16:50:02 +02:00

502 lines
22 KiB
TypeScript

// FE-MOB-ASET-001 to FE-MOB-ASET-024
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-024: a key the environment sets is read-only and names its variable (#1881)', async () => {
const user = userEvent.setup();
const admin = renderSettings({
keyInputProps: (field: string) =>
field === 'maps' ? { disabled: true, placeholder: 'Set via PLACES_API_KEY' } : { disabled: false, placeholder: 'Enter key...' },
mapsKeyTestable: true,
});
expect(screen.getByLabelText('Google Maps API Key')).toBeDisabled();
expect(screen.getByLabelText('Google Maps API Key')).toHaveAttribute('placeholder', 'Set via PLACES_API_KEY');
expect(screen.getByLabelText('Unsplash API Key')).toBeEnabled();
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-018b: the Google-only row says what it needs without a key and toggles through the hook', async () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'false');
await user.click(toggle('Search with Google only'));
expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1);
});
it('FE-MOB-ASET-018c: with a key the Google-only row explains what it changes, unless another provider holds the slot', async () => {
const user = userEvent.setup();
const { unmount } = render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument();
expect(toggle('Search with Google only')).toHaveAttribute('aria-checked', 'true');
unmount();
// The server ignores the switch under Amap or OpenStreetMap, and the row
// must not promise otherwise just because a key is stored.
render(<Harness admin={buildAdminHook({ hasMapsKey: true, placesGoogleOnly: true, placesProvider: 'amap' })} />);
await user.click(screen.getByRole('button', { name: 'What the key is used for' }));
expect(screen.queryByText(/Every search and every suggestion goes to Google Places/)).not.toBeInTheDocument();
expect(screen.queryByText(/Needs a Google Maps API key/)).not.toBeInTheDocument();
const row = toggle('Search with Google only').closest<HTMLElement>('.justify-between')!;
expect(row.textContent).toMatch(/provider/i);
});
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);
});
});