mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
47d9cce936
- Add new fields to AppConfig type and buildAppConfig factory - Update FE-PAGE-ADMIN-018: heading changed to "Authentication Methods" - Update FE-PAGE-ADMIN-053: oidc_only toggle removed from OIDC panel - Update FE-PAGE-LOGIN-007/017: mocks now include password_login/oidc_login - Update ADMIN-SVC-049: updateOidcSettings no longer writes oidc_only
1337 lines
52 KiB
TypeScript
1337 lines
52 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { server } from '../../tests/helpers/msw/server';
|
|
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
|
import { buildUser, buildAdmin } from '../../tests/helpers/factories';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useAddonStore } from '../store/addonStore';
|
|
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/AddonManager', () => ({
|
|
default: () => <div data-testid="addon-manager" />,
|
|
}));
|
|
|
|
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" />,
|
|
}));
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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: 'securepassword123' } });
|
|
|
|
// 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.getAllByTitle('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('.bg-white');
|
|
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.getAllByTitle(/delete/i);
|
|
fireEvent.click(deleteButtons[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.getAllByTitle('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.getAllByTitle('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('.bg-white');
|
|
const mfaToggle = within(mfaCard!).getByRole('button');
|
|
fireEvent.click(mfaToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(capturedBody).toEqual(expect.objectContaining({ require_mfa: true }));
|
|
});
|
|
});
|
|
});
|
|
|
|
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.getByTitle('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.getByTitle(/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'],
|
|
available_channels: { inapp: true, email: 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 }));
|
|
|
|
const keyInput = await screen.findByPlaceholderText('Enter key...');
|
|
|
|
// 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('.bg-white');
|
|
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('.bg-white');
|
|
|
|
// 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('.bg-white');
|
|
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('.bg-white');
|
|
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 Close button to dismiss the modal
|
|
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
|
|
|
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.getAllByTitle('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('.bg-white');
|
|
|
|
// 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' });
|
|
});
|
|
});
|
|
});
|
|
|
|
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('.bg-white');
|
|
|
|
// 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' } });
|
|
|
|
// Click the validate (Test) button for maps key — first "Test" button in the card
|
|
const testBtns = within(apiKeysCard!).getAllByRole('button', { name: /^test$/i });
|
|
fireEvent.click(testBtns[0]);
|
|
|
|
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.getAllByTitle('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.getAllByTitle('Delete user');
|
|
fireEvent.click(deleteButtons[deleteButtons.length - 1]); // last button = alice
|
|
|
|
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('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('.bg-white');
|
|
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 });
|
|
const webhookCard = webhookHeading.closest('.bg-white');
|
|
// 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('.bg-white');
|
|
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'],
|
|
available_channels: { email: 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('.bg-white');
|
|
|
|
// 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('.bg-white');
|
|
|
|
// 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('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('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');
|
|
});
|
|
});
|
|
});
|