mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
03d6457bde
PAD-05B: a click on an obstacle on the map or on the route band opens its card in the look of the via ferrata card: the kind with its glyph, the name, how it stands to the route (on it at a distance along it, near it with the link marked unclear, or only in the area), the paddling rule its tags give with canoe read before boat, a condition as written, height, grade, hours and fees, the source with the date of the data and the OpenStreetMap link. A missing rule reads as unknown, never as allowed, and a portage way recorded as private says so. Objects only in the area show from zoom 13 on, on Leaflet and GL alike; on GL the lines only near the route are dashed as on Leaflet, one click handler picks the marker over the line, and a picked marker no longer flickers. The route band marks the obstacles on and near the route by their share of the measured line. The obstacle notice also names an answer cut at its limit. PAD-05A: the admin area gets a Paddle water data card under the addons, on desktop and phone: a search in Geofabrik's list with each region's path, the regions with their state (waiting, reading with its progress, ready with objects and data date, failed with the reason), reading again and removing after a confirmation. The list follows a read without reloading. Region changes write audit rows. Map colours of paddle objects and portages come from theme tokens read at draw time, as the via ferratas do; the ferrata layer now shares that reader. The rail takes the planner page's parts as one bag, the map hit tests of the tour layers are one helper, and the unused noWaterData string is gone from every locale.
1838 lines
73 KiB
TypeScript
1838 lines
73 KiB
TypeScript
import { delay, http, HttpResponse } from 'msw';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { buildAdmin } from '../../tests/helpers/factories';
|
|
import { server } from '../../tests/helpers/msw/server';
|
|
import { fireEvent, render, screen, waitFor, within } from '../../tests/helpers/render';
|
|
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
|
import { ToastContainer } from '../components/shared/Toast';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import AdminPage from './AdminPage';
|
|
|
|
// Mock heavy sub-panels to focus on page-level concerns
|
|
vi.mock('../components/Admin/CategoryManager', () => ({
|
|
default: () => <div data-testid="category-manager" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/BackupPanel', () => ({
|
|
default: () => <div data-testid="backup-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/GitHubPanel', () => ({
|
|
default: () => <div data-testid="github-panel" />,
|
|
}));
|
|
vi.mock('../components/Admin/paddle/PaddleRegionsPanel', () => ({
|
|
default: () => <div data-testid="paddle-regions-panel" />,
|
|
}));
|
|
|
|
// AddonManager is stubbed but keeps its two callbacks reachable — the page owns
|
|
// the optimistic toggle logic behind them.
|
|
vi.mock('../components/Admin/AddonManager', () => ({
|
|
default: ({
|
|
bagTrackingEnabled,
|
|
onToggleBagTracking,
|
|
collabFeatures,
|
|
onToggleCollabFeature,
|
|
}: {
|
|
bagTrackingEnabled: boolean;
|
|
onToggleBagTracking: () => void;
|
|
collabFeatures: Record<string, boolean>;
|
|
onToggleCollabFeature: (key: string) => void;
|
|
}) => (
|
|
<div data-testid="addon-manager">
|
|
<span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span>
|
|
<span data-testid="collab-chat-state">{String(collabFeatures?.chat)}</span>
|
|
<span data-testid="collab-notes-state">{String(collabFeatures?.notes)}</span>
|
|
<button onClick={() => onToggleBagTracking()}>toggle bag tracking</button>
|
|
<button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
|
|
<button onClick={() => onToggleCollabFeature('notes')}>toggle notes</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../components/Admin/PackingTemplateManager', () => ({
|
|
default: () => <div data-testid="packing-template-manager" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/AuditLogPanel', () => ({
|
|
default: () => <div data-testid="audit-log-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/AdminMcpTokensPanel', () => ({
|
|
default: () => <div data-testid="mcp-tokens-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/PermissionsPanel', () => ({
|
|
default: () => <div data-testid="permissions-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/DevNotificationsPanel', () => ({
|
|
default: () => <div data-testid="dev-notifications-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/AdminPluginsPanel', () => ({
|
|
default: () => <div data-testid="plugins-panel" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/DefaultUserSettingsTab', () => ({
|
|
default: () => <div data-testid="default-user-settings" />,
|
|
}));
|
|
|
|
vi.mock('../components/Admin/storage/AdminStoragePanel', () => ({
|
|
default: () => <div data-testid="admin-storage-panel" />,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
});
|
|
|
|
describe('AdminPage', () => {
|
|
describe('FE-PAGE-ADMIN-001: Regular user is redirected away from admin', () => {
|
|
it('admin page renders correctly with admin user (guard is at router level)', async () => {
|
|
// Protection is at the ProtectedRoute level in App.tsx (role check).
|
|
// When rendered directly with an admin user, page shows admin content.
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
// Users tab is the default — it's a button with exact text "Users"
|
|
expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-002: Admin user sees the admin panel', () => {
|
|
it('renders tabs including Users when logged in as admin', async () => {
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
// Users tab is the default active tab
|
|
expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-003: User management list loads', () => {
|
|
it('loads and displays the user list from the API', async () => {
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
// Users are fetched from GET /api/admin/users
|
|
await waitFor(() => {
|
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-004: System stats displayed', () => {
|
|
it('displays stat numbers from the API', async () => {
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
// Stats are on the users tab: totalUsers, totalTrips, totalPlaces, totalFiles
|
|
await waitFor(() => {
|
|
// The stats panel shows "2 users" or similar numbers
|
|
expect(screen.getByText('2')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-005: Tabs are present', () => {
|
|
it('renders all standard admin tabs', async () => {
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
// Other tabs
|
|
expect(screen.getByRole('button', { name: /personalization/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /addons/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /settings/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-006: Error handling when data load fails', () => {
|
|
it('does not crash when admin API returns error', async () => {
|
|
server.use(
|
|
http.get('/api/admin/users', () => {
|
|
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
}),
|
|
http.get('/api/admin/stats', () => {
|
|
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, {
|
|
isAuthenticated: true,
|
|
user: buildAdmin(),
|
|
});
|
|
|
|
render(<AdminPage />);
|
|
|
|
// Page should still render (error is handled internally)
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-007: Tab switching renders correct panel', () => {
|
|
it('clicking Personalization tab shows category-manager and hides users tab content', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
// category-manager not present on default users tab
|
|
expect(screen.queryByTestId('category-manager')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /personalization/i }));
|
|
|
|
expect(screen.getByTestId('category-manager')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-008: Addons tab renders AddonManager', () => {
|
|
it('clicking Addons tab shows addon-manager', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
|
|
expect(screen.getByTestId('addon-manager')).toBeInTheDocument();
|
|
// The paddle water data sit under the addons; the panel hides itself while Tours is off.
|
|
expect(screen.getByTestId('paddle-regions-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-009: Backup tab renders BackupPanel', () => {
|
|
it('clicking Backup tab shows backup-panel', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^backup$/i }));
|
|
|
|
expect(screen.getByTestId('backup-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-010: Audit tab renders AuditLogPanel', () => {
|
|
it('clicking Audit tab shows audit-log-panel', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^audit$/i }));
|
|
|
|
expect(screen.getByTestId('audit-log-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-011: GitHub tab renders GitHubPanel', () => {
|
|
it('clicking GitHub tab shows github-panel', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^github$/i }));
|
|
|
|
expect(screen.getByTestId('github-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-012: Stats card values displayed', () => {
|
|
it('shows totalPlaces (42) and totalFiles (8) from GET /api/admin/stats', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('42')).toBeInTheDocument(); // totalPlaces — unique on page
|
|
expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-013: Create user modal opens', () => {
|
|
it('clicking Create User button opens modal with username/email/password fields', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
|
|
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-014: Create user submits form', () => {
|
|
it('submitting the create user form adds the new user to the list', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } });
|
|
fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'newuser@example.com' } });
|
|
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'SecurePassw0rd!23' } });
|
|
|
|
// The modal footer has a second "Create User" button
|
|
const createButtons = screen.getAllByRole('button', { name: /create user/i });
|
|
fireEvent.click(createButtons[createButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('newuser')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-015: Edit user modal opens', () => {
|
|
it('clicking edit button for alice pre-fills the edit form with alice', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
// MSW returns [admin, alice] — alice's edit button is at index 1
|
|
const editButtons = screen.getAllByLabelText('Edit User');
|
|
fireEvent.click(editButtons[1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByDisplayValue('alice')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-016: Version update banner shown when update available', () => {
|
|
it('shows update available banner when version-check returns update_available: true', async () => {
|
|
server.use(
|
|
http.get('/api/admin/version-check', () => {
|
|
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/update available/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-017: MCP Tokens tab only visible when MCP addon enabled', () => {
|
|
it('does not show MCP Tokens tab when MCP is disabled', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
expect(screen.queryByRole('button', { name: /mcp access/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows MCP Tokens tab button when MCP addon is enabled', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () => {
|
|
return HttpResponse.json({
|
|
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /mcp access/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-018: Registration toggle in Settings tab', () => {
|
|
it('clicking the registration toggle calls PUT /api/auth/app-settings', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const heading = await screen.findByRole('heading', { name: /authentication methods/i });
|
|
const card = heading.closest<HTMLElement>('section');
|
|
const toggles = within(card!).getAllByRole('button');
|
|
fireEvent.click(toggles[0]); // First toggle = password_login
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toEqual(expect.objectContaining({ password_login: false }));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-019: Invite link creation', () => {
|
|
it('creating an invite shows the invite token in the list', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create link/i }));
|
|
|
|
const submitBtn = await screen.findByRole('button', { name: /create & copy/i });
|
|
fireEvent.click(submitBtn);
|
|
|
|
// MSW returns token: 'test-invite-token'; display shows first 12 chars
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/test-invite-/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-020: Delete user', () => {
|
|
it('clicking delete for a user removes them from the list', async () => {
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
// MSW returns [admin, alice]; alice's delete button is index 1
|
|
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
|
fireEvent.click(deleteButtons[1]);
|
|
// The desktop tab asks in its own dialog; its confirm button renders last.
|
|
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
|
|
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-021: Edit user save', () => {
|
|
it('editing and saving a user updates the user list', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
const editButtons = screen.getAllByLabelText('Edit User');
|
|
fireEvent.click(editButtons[1]);
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.change(screen.getByDisplayValue('alice'), { target: { value: 'alicemodified' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('alicemodified')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-022: Cancel edit user modal', () => {
|
|
it('clicking Cancel in the edit modal closes the modal', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
const editButtons = screen.getAllByLabelText('Edit User');
|
|
fireEvent.click(editButtons[1]);
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('alice')).toBeInTheDocument());
|
|
|
|
const cancelBtns = screen.getAllByRole('button', { name: /^cancel$/i });
|
|
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByDisplayValue('alice')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-023: Require MFA toggle in Settings tab', () => {
|
|
it('clicking the MFA toggle calls PUT /api/auth/app-settings with require_mfa', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const mfaHeading = await screen.findByRole('heading', { name: /require two-factor/i });
|
|
const mfaCard = mfaHeading.closest<HTMLElement>('section');
|
|
const mfaToggle = within(mfaCard!).getByRole('button');
|
|
fireEvent.click(mfaToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toEqual(expect.objectContaining({ require_mfa: true }));
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The transit-provider trigger. CustomSelect renders a plain button whose accessible
|
|
* name is the selected option, so it is found through its own card rather than by
|
|
* role and label the way the native select was.
|
|
*/
|
|
function transitTrigger(): HTMLElement {
|
|
const block = screen.getByText('Transit Provider').closest<HTMLElement>('section');
|
|
return within(block!).getByRole('button');
|
|
}
|
|
|
|
describe('FE-PAGE-ADMIN-023b: Transit provider select in Settings tab (#1699)', () => {
|
|
it('choosing Google calls PUT /api/admin/transit-provider', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.get('/api/admin/transit-provider', () =>
|
|
HttpResponse.json({ provider: 'transitous', googleKeySource: 'instance' })),
|
|
http.put('/api/admin/transit-provider', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
await screen.findByText('Transit Provider');
|
|
await waitFor(() => expect(transitTrigger()).toHaveTextContent('Transitous'));
|
|
fireEvent.click(transitTrigger());
|
|
fireEvent.click(screen.getByText('Google'));
|
|
|
|
await waitFor(() => expect(capturedBody).toEqual({ provider: 'google' }));
|
|
expect(transitTrigger()).toHaveTextContent('Google');
|
|
});
|
|
|
|
it('FE-PAGE-ADMIN-023d: re-picking the provider already selected sends no request', async () => {
|
|
let puts = 0;
|
|
server.use(
|
|
http.get('/api/admin/transit-provider', () =>
|
|
HttpResponse.json({ provider: 'transitous', googleKeySource: 'instance' })),
|
|
http.put('/api/admin/transit-provider', () => {
|
|
puts += 1;
|
|
return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
await screen.findByText('Transit Provider');
|
|
|
|
// A real change first, so the counter is proven to move at all.
|
|
fireEvent.click(transitTrigger());
|
|
fireEvent.click(screen.getByText('Google'));
|
|
await waitFor(() => expect(puts).toBe(1));
|
|
|
|
// Picking the same option again must not send a second PUT. Trigger and menu
|
|
// entry carry the same label, so the entry is the one that is not the trigger.
|
|
const trigger = transitTrigger();
|
|
fireEvent.click(trigger);
|
|
const entry = screen.getAllByRole('button', { name: /^google$/i }).find(b => b !== trigger);
|
|
fireEvent.click(entry!);
|
|
|
|
await waitFor(() => expect(transitTrigger()).toHaveTextContent('Google'));
|
|
expect(puts).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-023c: Transit provider key warnings (#1699)', () => {
|
|
async function openSettingsWith(googleKeySource: string | null) {
|
|
server.use(
|
|
http.get('/api/admin/transit-provider', () =>
|
|
HttpResponse.json({ provider: 'google', googleKeySource })),
|
|
);
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
await screen.findByText('Transit Provider');
|
|
await waitFor(() => expect(transitTrigger()).toHaveTextContent('Google'));
|
|
}
|
|
|
|
it('warns that Google is selected but no key is configured', async () => {
|
|
await openSettingsWith(null);
|
|
expect(await screen.findByText(/no google api key is configured/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('warns that only the admin\'s own key is set, so others fall back', async () => {
|
|
await openSettingsWith('user-row');
|
|
expect(await screen.findByText(/only your own google key is set/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('stays quiet when an instance-wide key resolves', async () => {
|
|
await openSettingsWith('instance');
|
|
expect(screen.queryByText(/no google api key is configured/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/only your own google key is set/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-024: JWT rotation modal opens from Danger Zone', () => {
|
|
it('clicking Rotate in Danger Zone opens the JWT rotation confirmation modal', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const rotateBtn = await screen.findByRole('button', { name: /^rotate$/i });
|
|
fireEvent.click(rotateBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /rotate jwt secret/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-025: Cancel create user modal', () => {
|
|
it('clicking Cancel in the create user modal closes it', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
|
|
|
|
const cancelBtns = screen.getAllByRole('button', { name: /^cancel$/i });
|
|
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByPlaceholderText('Username')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-026: Cancel create invite modal', () => {
|
|
it('clicking Cancel in the invite modal closes it', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create link/i }));
|
|
await screen.findByRole('button', { name: /create & copy/i });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('button', { name: /create & copy/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-027: Delete invite from the invite list', () => {
|
|
it('clicking the delete button on an invite removes it from the list', async () => {
|
|
server.use(
|
|
http.get('/api/admin/invites', () => {
|
|
return HttpResponse.json({
|
|
invites: [
|
|
{
|
|
id: 1,
|
|
token: 'abcdef123456789',
|
|
max_uses: 5,
|
|
used_count: 0,
|
|
expires_at: null,
|
|
created_by_name: 'admin',
|
|
},
|
|
],
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText(/abcdef123456/)).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByLabelText('Delete'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/abcdef123456/)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-028: Copy invite link', () => {
|
|
it('clicking the copy button on an active invite calls clipboard.writeText', async () => {
|
|
const writeTextSpy = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText: writeTextSpy },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
server.use(
|
|
http.get('/api/admin/invites', () => {
|
|
return HttpResponse.json({
|
|
invites: [
|
|
{
|
|
id: 1,
|
|
token: 'abcdef123456789',
|
|
max_uses: 5,
|
|
used_count: 0,
|
|
expires_at: null,
|
|
created_by_name: 'admin',
|
|
},
|
|
],
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText(/abcdef123456/)).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByLabelText(/copy link/i));
|
|
|
|
await waitFor(() => {
|
|
expect(writeTextSpy).toHaveBeenCalledWith(expect.stringContaining('abcdef123456789'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-029: Notifications tab renders email and webhook panels', () => {
|
|
it('clicking Notifications tab shows Email SMTP and Webhook panels', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /email \(smtp\)/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-030: AdminNotificationsPanel renders with matrix data', () => {
|
|
it('shows notification matrix when preferences API returns event_types', async () => {
|
|
server.use(
|
|
http.get('/api/admin/notification-preferences', () => {
|
|
return HttpResponse.json({
|
|
event_types: ['version_available'],
|
|
channels: [
|
|
{
|
|
id: 'inapp',
|
|
source: 'builtin',
|
|
labelKey: 'settings.notificationPreferences.inapp',
|
|
active: true,
|
|
configured: true,
|
|
},
|
|
{
|
|
id: 'email',
|
|
source: 'builtin',
|
|
labelKey: 'settings.notificationPreferences.email',
|
|
active: true,
|
|
configured: true,
|
|
},
|
|
],
|
|
implemented_combos: { version_available: ['inapp', 'email'] },
|
|
preferences: { version_available: { inapp: true, email: true } },
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// AdminNotificationsPanel heading for admin notifications
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: /^notifications$/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-031: MCP Tokens tab renders its panel', () => {
|
|
it('clicking MCP Tokens tab shows the mcp-tokens-panel', async () => {
|
|
// Override /api/addons so the Navbar's loadAddons keeps MCP enabled
|
|
server.use(
|
|
http.get('/api/addons', () => {
|
|
return HttpResponse.json({
|
|
addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }],
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /mcp access/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /mcp access/i }));
|
|
|
|
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-032: Update instructions modal', () => {
|
|
it('clicking How to Update opens the docker instructions modal', async () => {
|
|
server.use(
|
|
http.get('/api/admin/version-check', () => {
|
|
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText(/update available/i)).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /how to update/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/docker pull/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-033: Create user validation — empty fields', () => {
|
|
it('keeps the modal open and shows a toast when required fields are empty', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
|
|
|
|
// Submit without filling fields — modal stays open
|
|
const createButtons = screen.getAllByRole('button', { name: /create user/i });
|
|
fireEvent.click(createButtons[createButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-034: API key field interaction in Settings tab', () => {
|
|
it('can type in the maps API key and toggle visibility', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Maps is the first 'Enter key...' input (Unsplash is the second).
|
|
const keyInput = (await screen.findAllByPlaceholderText('Enter key...'))[0];
|
|
|
|
// Type a value — covers the onChange handler
|
|
fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } });
|
|
expect((keyInput as HTMLInputElement).value).toBe('test-api-key-abc123');
|
|
|
|
// Click the eye button to toggle visibility — covers toggleKey
|
|
const eyeBtn = keyInput.parentElement?.querySelector('button[type="button"]');
|
|
if (eyeBtn) fireEvent.click(eyeBtn as HTMLElement);
|
|
|
|
expect(keyInput).toHaveAttribute('type', 'text');
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-035: File types save in Settings tab', () => {
|
|
it('changing and saving file types calls PUT /api/auth/app-settings', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Find the file types input by placeholder
|
|
const fileTypesInput = await screen.findByPlaceholderText(/jpg,png,pdf/i);
|
|
fireEvent.change(fileTypesInput, { target: { value: 'jpg,png' } });
|
|
|
|
// Find and click the Save button in the file types section
|
|
const fileTypesHeading = screen.getByRole('heading', { name: /allowed file types/i });
|
|
const fileTypesCard = fileTypesHeading.closest<HTMLElement>('section');
|
|
const saveBtn = within(fileTypesCard!).getByRole('button', { name: /save/i });
|
|
fireEvent.click(saveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toEqual(expect.objectContaining({ allowed_file_types: 'jpg,png' }));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-036: OIDC configuration in Settings tab', () => {
|
|
it('typing in OIDC inputs and clicking Save calls adminApi.updateOidc', async () => {
|
|
server.use(
|
|
http.put('/api/admin/oidc', async ({ request }) => {
|
|
return HttpResponse.json(await request.json());
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Wait for OIDC section to appear
|
|
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i });
|
|
const oidcCard = oidcHeading.closest<HTMLElement>('section');
|
|
|
|
// Type in the display name field (placeholder is 'z.B. Google, Authentik, Keycloak')
|
|
const displayNameInput = within(oidcCard!).getByPlaceholderText('z.B. Google, Authentik, Keycloak');
|
|
fireEvent.change(displayNameInput, { target: { value: 'Google' } });
|
|
|
|
// Click the Save button in the OIDC section
|
|
const oidcSaveBtn = within(oidcCard!).getByRole('button', { name: /save/i });
|
|
fireEvent.click(oidcSaveBtn);
|
|
|
|
// Button was clicked without error
|
|
await waitFor(() => {
|
|
expect(oidcHeading).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-037: Notifications tab email channel toggle', () => {
|
|
it('clicking the email toggle enables the channel and calls PUT /api/auth/app-settings', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// The Email (SMTP) panel header has the enable toggle
|
|
const emailHeading = await screen.findByRole('heading', { name: /email \(smtp\)/i });
|
|
const emailPanel = emailHeading.closest<HTMLElement>('section');
|
|
const emailToggle = within(emailPanel!).getAllByRole('button')[0];
|
|
fireEvent.click(emailToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-038: Notifications tab save SMTP settings', () => {
|
|
it('clicking Save in the email panel calls PUT /api/auth/app-settings with SMTP keys', async () => {
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
// Start with email enabled by seeding smtpValues
|
|
server.use(
|
|
http.get('/api/auth/app-settings', () => {
|
|
return HttpResponse.json({ notification_channels: 'email', smtp_host: 'mail.example.com' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// Wait for the SMTP inputs to be visible (email is active)
|
|
const smtpHostInput = await screen.findByPlaceholderText('mail.example.com');
|
|
expect(smtpHostInput).toBeInTheDocument();
|
|
|
|
// Type in the SMTP host field (covers SMTP input onChange)
|
|
fireEvent.change(smtpHostInput, { target: { value: 'smtp.gmail.com' } });
|
|
|
|
// Click Save in the email panel
|
|
const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i });
|
|
const emailPanel = emailHeading.closest<HTMLElement>('section');
|
|
const saveBtn = within(emailPanel!).getByRole('button', { name: /^save$/i });
|
|
fireEvent.click(saveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-039: Create user short password validation', () => {
|
|
it('shows error and keeps modal open when password is too short', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } });
|
|
fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'newuser@example.com' } });
|
|
// Short password (< 8 chars)
|
|
fireEvent.change(screen.getByPlaceholderText('Password'), { target: { value: 'short' } });
|
|
|
|
const createButtons = screen.getAllByRole('button', { name: /create user/i });
|
|
fireEvent.click(createButtons[createButtons.length - 1]);
|
|
|
|
// Modal stays open — password validation error
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText('Username')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-040: Close update instructions modal', () => {
|
|
it('clicking Close button dismisses the update instructions modal', async () => {
|
|
server.use(
|
|
http.get('/api/admin/version-check', () => {
|
|
return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText(/update available/i)).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /how to update/i }));
|
|
await waitFor(() => expect(screen.getByText(/docker pull/i)).toBeInTheDocument());
|
|
|
|
// Click the footer's Close button to dismiss the modal (the head band's X is the other one)
|
|
const closeButtons = screen.getAllByRole('button', { name: /^close$/i });
|
|
fireEvent.click(closeButtons[closeButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/docker pull/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-041: Cancel JWT rotation modal', () => {
|
|
it('clicking Cancel in the JWT rotation modal closes it', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const rotateBtn = await screen.findByRole('button', { name: /^rotate$/i });
|
|
fireEvent.click(rotateBtn);
|
|
|
|
await waitFor(() => expect(screen.getByRole('heading', { name: /rotate jwt secret/i })).toBeInTheDocument());
|
|
|
|
// Click Cancel to close
|
|
const cancelBtns = screen.getAllByRole('button', { name: /^cancel$/i });
|
|
fireEvent.click(cancelBtns[cancelBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('heading', { name: /rotate jwt secret/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-042: Edit user — change email field', () => {
|
|
it('typing in the email field of the edit modal updates the form value', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
const editButtons = screen.getAllByLabelText('Edit User');
|
|
fireEvent.click(editButtons[1]);
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('alice')).toBeInTheDocument());
|
|
|
|
// Change email field (covers onChange in edit modal)
|
|
fireEvent.change(screen.getByDisplayValue('alice@example.com'), {
|
|
target: { value: 'alice-new@example.com' },
|
|
});
|
|
|
|
expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value).toBe(
|
|
'alice-new@example.com'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-043: Save API keys in Settings tab', () => {
|
|
it('typing in the maps API key and clicking Save calls PUT /api/auth/me/api-keys', async () => {
|
|
let capturedBody: unknown;
|
|
server.use(
|
|
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
|
capturedBody = await request.json();
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Wait for the API Keys section to appear
|
|
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
|
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('section');
|
|
|
|
// Type in the maps key field (type="password" by default)
|
|
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
|
fireEvent.change(keyInputs[0], { target: { value: 'test-maps-key-123' } });
|
|
|
|
// Find the Save button in the API Keys card
|
|
const saveBtn = within(apiKeysCard!).getByRole('button', { name: /^save$/i });
|
|
fireEvent.click(saveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' });
|
|
});
|
|
});
|
|
|
|
it('typing in the Unsplash API key and clicking Save sends unsplash_api_key', async () => {
|
|
let capturedBody: Record<string, unknown> | undefined;
|
|
server.use(
|
|
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
|
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('section');
|
|
|
|
// By accessible name, not by position: the card gained an Amap field
|
|
// between Maps and Unsplash, and an index would have kept passing while
|
|
// asserting about the wrong input. The name sits on the show/hide toggle;
|
|
// the input is its sibling.
|
|
const unsplashToggle = within(apiKeysCard!).getByLabelText('Unsplash API Key');
|
|
fireEvent.change(unsplashToggle.parentElement!.querySelector('input')!, {
|
|
target: { value: 'test-unsplash-key' },
|
|
});
|
|
|
|
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
|
|
});
|
|
});
|
|
|
|
it('typing in the Amap API key and clicking Save sends amap_api_key', async () => {
|
|
let capturedBody: Record<string, unknown> | undefined;
|
|
server.use(
|
|
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
|
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('section');
|
|
|
|
const amapToggle = within(apiKeysCard!).getByLabelText(/Amap/);
|
|
fireEvent.change(amapToggle.parentElement!.querySelector('input')!, {
|
|
target: { value: 'test-amap-key' },
|
|
});
|
|
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody?.amap_api_key).toBe('test-amap-key');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
|
|
it('clicking the Test button for maps key calls validate-keys endpoint', async () => {
|
|
server.use(
|
|
http.put('/api/auth/me/api-keys', async () => {
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
http.get('/api/auth/validate-keys', () => {
|
|
return HttpResponse.json({ maps: true, weather: false });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Wait for the API Keys section
|
|
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
|
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('section');
|
|
|
|
// Type a key value to enable the Test button
|
|
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
|
fireEvent.change(keyInputs[0], { target: { value: 'test-maps-key' } });
|
|
|
|
// The maps key is the only one with a Test button now: weather left the card
|
|
// when it stopped needing a key at all.
|
|
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^test$/i }));
|
|
|
|
await waitFor(() => {
|
|
// After validation, valid indicator appears (admin.keyValid = 'Connected')
|
|
expect(screen.queryByText(/connected/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-045: Edit user with short password shows error', () => {
|
|
it('entering a password shorter than 8 chars shows error and keeps modal open', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
const editButtons = screen.getAllByLabelText('Edit User');
|
|
fireEvent.click(editButtons[1]); // click alice's edit button
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('alice')).toBeInTheDocument());
|
|
|
|
// Enter a short password (< 8 chars) — placeholder is 'Enter new password…'
|
|
const passwordInput = screen.getByPlaceholderText('Enter new password…');
|
|
fireEvent.change(passwordInput, { target: { value: 'short' } });
|
|
|
|
const saveBtn = screen.getByRole('button', { name: /^save$/i });
|
|
fireEvent.click(saveBtn);
|
|
|
|
await waitFor(() => {
|
|
// Modal should remain open — the username field is still there
|
|
expect(screen.getByDisplayValue('alice')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-046: Delete user calls DELETE endpoint', () => {
|
|
it('clicking delete on a user (confirming) calls DELETE /api/admin/users/:id', async () => {
|
|
let deletedId: string | undefined;
|
|
server.use(
|
|
http.delete('/api/admin/users/:id', ({ params }) => {
|
|
deletedId = params.id as string;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
|
|
|
// Mock confirm to return true so delete proceeds
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
// Click delete for alice (second user — non-self)
|
|
const deleteButtons = screen.getAllByLabelText('Delete user');
|
|
fireEvent.click(deleteButtons[deleteButtons.length - 1]); // last button = alice
|
|
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
|
|
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(deletedId).toBeDefined();
|
|
});
|
|
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-047: JWT rotation confirm button', () => {
|
|
it('clicking Rotate & Log out calls rotateJwtSecret endpoint', async () => {
|
|
let rotateCalled = false;
|
|
server.use(
|
|
http.post('/api/admin/rotate-jwt-secret', () => {
|
|
rotateCalled = true;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
const rotateBtn = await screen.findByRole('button', { name: /^rotate$/i });
|
|
fireEvent.click(rotateBtn);
|
|
|
|
await waitFor(() => expect(screen.getByRole('heading', { name: /rotate jwt secret/i })).toBeInTheDocument());
|
|
|
|
// Click the confirm button "Rotate & Log out"
|
|
const confirmBtn = screen.getByRole('button', { name: /rotate.*log out/i });
|
|
fireEvent.click(confirmBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(rotateCalled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-048: Notifications SMTP TLS toggle', () => {
|
|
it('clicking the TLS toggle changes the smtp_skip_tls_verify value', async () => {
|
|
server.use(
|
|
http.get('/api/auth/app-settings', () => {
|
|
return HttpResponse.json({
|
|
notification_channels: 'email',
|
|
smtp_host: 'mail.example.com',
|
|
smtp_skip_tls_verify: 'false',
|
|
});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
|
|
|
// Wait for SMTP host input to appear (email is active)
|
|
await screen.findByPlaceholderText('mail.example.com');
|
|
|
|
// Click the TLS toggle (skip TLS certificate check)
|
|
const tlsToggleText = screen.getByText('Skip TLS certificate check');
|
|
const tlsCard = tlsToggleText.closest<HTMLElement>('div');
|
|
// The toggle button is a sibling container
|
|
const allToggles = screen.getAllByRole('button');
|
|
// Find toggle near the TLS text
|
|
const tlsSection = tlsToggleText.parentElement?.parentElement;
|
|
const tlsToggle = tlsSection?.querySelector('button');
|
|
if (tlsToggle) {
|
|
fireEvent.click(tlsToggle);
|
|
// After click, the value should be toggled (visual change, no API call for this toggle)
|
|
expect(tlsToggle).toBeInTheDocument();
|
|
} else {
|
|
// Alternative: click all buttons and check if something changes
|
|
expect(allToggles.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-049: Test SMTP button', () => {
|
|
it('clicking Send test email button calls test-smtp endpoint', async () => {
|
|
let testSmtpCalled = false;
|
|
server.use(
|
|
http.get('/api/auth/app-settings', () => {
|
|
return HttpResponse.json({
|
|
notification_channels: 'email',
|
|
smtp_host: 'mail.example.com',
|
|
});
|
|
}),
|
|
http.post('/api/notifications/test-smtp', () => {
|
|
testSmtpCalled = true;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// Wait for email panel to be active (smtp_host is configured)
|
|
await screen.findByPlaceholderText('mail.example.com');
|
|
|
|
// Find the email panel and click its "Send test email" button (scoped to avoid admin webhook panel)
|
|
const emailHeading = screen.getByRole('heading', { name: /email \(smtp\)/i });
|
|
const emailPanel = emailHeading.closest<HTMLElement>('section');
|
|
const testBtn = within(emailPanel!).getByRole('button', { name: /send test email/i });
|
|
fireEvent.click(testBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(testSmtpCalled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-050: Webhook channel toggle', () => {
|
|
it('clicking the webhook toggle calls setChannels', async () => {
|
|
let appSettingsCalled = false;
|
|
server.use(
|
|
http.get('/api/auth/app-settings', () => {
|
|
return HttpResponse.json({
|
|
notification_channels: 'email',
|
|
smtp_host: 'mail.example.com',
|
|
});
|
|
}),
|
|
http.put('/api/auth/app-settings', async () => {
|
|
appSettingsCalled = true;
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// Wait for notifications tab to load
|
|
await screen.findByPlaceholderText('mail.example.com');
|
|
|
|
// Find the webhook panel heading ('Webhook') — exact match to avoid 'Admin Webhook'
|
|
const webhookHeading = screen.getByRole('heading', { name: /^webhook$/i });
|
|
// Each channel is a settings card, found as the section around its heading.
|
|
const webhookCard = webhookHeading.closest<HTMLElement>('section');
|
|
// Find the toggle button in webhook card
|
|
const webhookToggle = within(webhookCard!).getByRole('button');
|
|
fireEvent.click(webhookToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(appSettingsCalled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-051: Admin webhook URL save', () => {
|
|
it('typing a webhook URL and clicking Save calls PUT /api/auth/app-settings', async () => {
|
|
let savedPayload: unknown;
|
|
server.use(
|
|
http.get('/api/auth/app-settings', () => {
|
|
return HttpResponse.json({
|
|
notification_channels: 'none',
|
|
});
|
|
}),
|
|
http.put('/api/auth/app-settings', async ({ request }) => {
|
|
savedPayload = await request.json();
|
|
return HttpResponse.json({});
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// Wait for the admin webhook panel to render
|
|
const webhookUrlInput = await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
|
|
fireEvent.change(webhookUrlInput, { target: { value: 'https://discord.com/api/webhooks/123/abc' } });
|
|
|
|
// Find the Save button in the admin webhook panel
|
|
const adminWebhookHeading = screen.getByRole('heading', { name: /admin webhook/i });
|
|
const adminWebhookCard = adminWebhookHeading.closest<HTMLElement>('section');
|
|
const saveBtn = within(adminWebhookCard!).getByRole('button', { name: /save/i });
|
|
fireEvent.click(saveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(savedPayload).toMatchObject({ admin_webhook_url: 'https://discord.com/api/webhooks/123/abc' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-052: AdminNotificationsPanel matrix toggle', () => {
|
|
it('clicking a preference toggle button in the matrix calls updateNotificationPreferences', async () => {
|
|
let prefUpdateCalled = false;
|
|
server.use(
|
|
http.get('/api/admin/notification-preferences', () => {
|
|
return HttpResponse.json({
|
|
event_types: ['trip.created'],
|
|
channels: [
|
|
{
|
|
id: 'email',
|
|
source: 'builtin',
|
|
labelKey: 'settings.notificationPreferences.email',
|
|
active: true,
|
|
configured: true,
|
|
},
|
|
],
|
|
implemented_combos: { 'trip.created': ['email'] },
|
|
preferences: { 'trip.created': { email: true } },
|
|
});
|
|
}),
|
|
http.put('/api/admin/notification-preferences', async () => {
|
|
prefUpdateCalled = true;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^notifications$/i }));
|
|
|
|
// Wait for the AdminNotificationsPanel matrix to appear
|
|
// The panel heading is t('admin.tabs.notifications') = 'Notifications'
|
|
// The channel column header is t('settings.notificationPreferences.email') = 'Email' (CSS uppercases it)
|
|
// Find the AdminNotificationsPanel by its h2 heading role='heading'
|
|
const matrixHeading = await screen.findByRole('heading', { name: /^notifications$/i });
|
|
const matrixCard = matrixHeading.closest<HTMLElement>('section');
|
|
|
|
// The matrix toggle button is inside the card (not a checkbox — it's a button toggle)
|
|
const matrixToggle = matrixCard?.querySelector('button');
|
|
if (matrixToggle) {
|
|
fireEvent.click(matrixToggle);
|
|
}
|
|
|
|
await waitFor(() => {
|
|
expect(prefUpdateCalled).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-053: OIDC remaining fields onChange', () => {
|
|
it('typing in OIDC issuer, client_id, client_secret fields covers onChange handlers', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
|
|
|
// Wait for the OIDC section — heading is 'Single Sign-On (OIDC)'
|
|
const oidcHeading = await screen.findByRole('heading', { name: /single sign-on/i });
|
|
const oidcCard = oidcHeading.closest<HTMLElement>('section');
|
|
|
|
// Issuer field (placeholder: https://accounts.google.com)
|
|
const issuerInput = within(oidcCard!).getByPlaceholderText('https://accounts.google.com');
|
|
fireEvent.change(issuerInput, { target: { value: 'https://accounts.google.com' } });
|
|
|
|
// Discovery URL field
|
|
const discoveryInput = within(oidcCard!).getByPlaceholderText(/openid-configuration/i);
|
|
fireEvent.change(discoveryInput, {
|
|
target: { value: 'https://auth.example.com/.well-known/openid-configuration' },
|
|
});
|
|
|
|
// Client ID field
|
|
const clientIdLabel = within(oidcCard!).getByText('Client ID');
|
|
const clientIdInput = clientIdLabel.closest<HTMLElement>('div')!.querySelector('input')!;
|
|
fireEvent.change(clientIdInput, { target: { value: 'my-client-id' } });
|
|
|
|
// Client Secret field
|
|
const clientSecretLabel = within(oidcCard!).getByText('Client Secret');
|
|
const clientSecretInput = clientSecretLabel.closest<HTMLElement>('div')!.querySelector('input')!;
|
|
fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } });
|
|
|
|
// Verify the inputs updated
|
|
expect((issuerInput as HTMLInputElement).value).toBe('https://accounts.google.com');
|
|
expect((clientIdInput as HTMLInputElement).value).toBe('my-client-id');
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-054: Demo baseline button', () => {
|
|
it('is hidden unless the instance runs in demo mode', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
expect(screen.queryByRole('button', { name: /save baseline/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('saves the baseline and reports success in demo mode', async () => {
|
|
let saved = false;
|
|
server.use(
|
|
http.post('/api/admin/save-demo-baseline', () => {
|
|
saved = true;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
|
|
render(
|
|
<>
|
|
<ToastContainer />
|
|
<AdminPage />
|
|
</>
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /save baseline/i }));
|
|
|
|
await waitFor(() => expect(saved).toBe(true));
|
|
expect(await screen.findByText(/baseline saved/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('surfaces the server error when the baseline cannot be saved', async () => {
|
|
server.use(
|
|
http.post('/api/admin/save-demo-baseline', () =>
|
|
HttpResponse.json({ error: 'Baseline locked' }, { status: 500 })
|
|
)
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
|
|
render(
|
|
<>
|
|
<ToastContainer />
|
|
<AdminPage />
|
|
</>
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /save baseline/i }));
|
|
|
|
expect(await screen.findByText('Baseline locked')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-055: Addons tab toggles', () => {
|
|
it('bag tracking flips optimistically and PUTs the new value', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false })),
|
|
http.put('/api/admin/bag-tracking', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ enabled: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle bag tracking/i }));
|
|
|
|
expect(screen.getByTestId('bag-tracking-state')).toHaveTextContent('true');
|
|
await waitFor(() => expect(body).toEqual({ enabled: true }));
|
|
});
|
|
|
|
it('bag tracking rolls back when the request fails', async () => {
|
|
server.use(
|
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false })),
|
|
http.put('/api/admin/bag-tracking', () => HttpResponse.json({}, { status: 500 }))
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle bag tracking/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('bag-tracking-state')).toHaveTextContent('false'));
|
|
});
|
|
|
|
it('a collab feature toggle PUTs only the changed key', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.get('/api/admin/collab-features', () =>
|
|
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
|
|
),
|
|
http.put('/api/admin/collab-features', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ chat: false }));
|
|
});
|
|
|
|
it('a failing collab toggle restores the previous feature set', async () => {
|
|
server.use(
|
|
http.get('/api/admin/collab-features', () =>
|
|
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
|
|
),
|
|
http.put('/api/admin/collab-features', () => HttpResponse.json({}, { status: 500 }))
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
|
});
|
|
|
|
// A snapshot-wide rollback would undo the second toggle as well, leaving the
|
|
// panel disagreeing with the server until a reload.
|
|
it('a failing collab toggle keeps a second toggle made while it was in flight', async () => {
|
|
server.use(
|
|
http.get('/api/admin/collab-features', () =>
|
|
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
|
|
),
|
|
http.put('/api/admin/collab-features', async ({ request }) => {
|
|
const body = (await request.json()) as Record<string, unknown>;
|
|
if ('chat' in body) {
|
|
await delay(30);
|
|
return HttpResponse.json({}, { status: 500 });
|
|
}
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /toggle notes/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
|
expect(screen.getByTestId('collab-notes-state')).toHaveTextContent('false');
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-056: Dev notifications tab', () => {
|
|
it('is hidden unless devMode is on and renders the panel when it is', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
const { unmount } = render(<AdminPage />);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
expect(screen.queryByRole('button', { name: /dev: notifications/i })).not.toBeInTheDocument();
|
|
unmount();
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), devMode: true });
|
|
render(<AdminPage />);
|
|
const devTab = await screen.findByRole('button', { name: /dev: notifications/i });
|
|
fireEvent.click(devTab);
|
|
|
|
expect(screen.getByTestId('dev-notifications-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-057: Remaining tab panels', () => {
|
|
it('renders the personalization, plugins and user-defaults panels', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^personalization$/i }));
|
|
expect(screen.getByTestId('packing-template-manager')).toBeInTheDocument();
|
|
expect(screen.getByTestId('category-manager')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^plugins$/i }));
|
|
expect(screen.getByTestId('plugins-panel')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /user defaults/i }));
|
|
expect(screen.getByTestId('default-user-settings')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-ADMIN-059: Stats fallbacks', () => {
|
|
it('shows a zero file count when the API omits totalFiles', async () => {
|
|
server.use(
|
|
http.get('/api/admin/stats', () =>
|
|
HttpResponse.json({ totalUsers: 3, totalTrips: 11, totalPlaces: 42 })
|
|
)
|
|
);
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
const filesLabel = await screen.findByText('Files');
|
|
expect(within(filesLabel.parentElement!).getByText('0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides the stats grid entirely when stats cannot be loaded', async () => {
|
|
server.use(http.get('/api/admin/stats', () => HttpResponse.json({}, { status: 500 })));
|
|
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
|
expect(screen.queryByText('Files')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('FE-PAGE-ADMIN-STOR-001: the Storage tab renders the storage panel', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
|
render(<AdminPage />);
|
|
const tab = await screen.findByRole('button', { name: /^storage$/i });
|
|
fireEvent.click(tab);
|
|
expect(screen.getByTestId('admin-storage-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PAGE-ADMIN-STOR-002: managed mode hides the Storage tab (hoster-level config)', async () => {
|
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
|
render(<AdminPage />);
|
|
await screen.findByRole('button', { name: /^users$/i });
|
|
expect(screen.queryByRole('button', { name: /^storage$/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|