diff --git a/packages/web/src/components/RequireRole.test.tsx b/packages/web/src/components/RequireRole.test.tsx index 56d2f917..55d33718 100644 --- a/packages/web/src/components/RequireRole.test.tsx +++ b/packages/web/src/components/RequireRole.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { RequireRole } from './RequireRole'; import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess'; @@ -10,6 +10,11 @@ vi.mock('../contexts/AuthContext', () => ({ useAuth: () => ({ currentUser: mockRole === undefined ? null : { role: mockRole } }), })); +// Tear down the DOM after every test (and between in-loop renders below) so a +// prior render's "PROTECTED PAGE" can never bleed into the next assertion — the +// redirect is exactly what we're verifying. +afterEach(() => cleanup()); + function renderGuarded(role: string | undefined | null, can: (r: any) => boolean) { mockRole = role; return render( @@ -23,31 +28,33 @@ function renderGuarded(role: string | undefined | null, can: (r: any) => boolean } describe('RequireRole (router guard)', () => { - it('renders the protected page for an allowed role', () => { + it('renders the protected page for an allowed role', async () => { renderGuarded('ADMIN', canAccessAdmin); - expect(screen.getByText('PROTECTED PAGE')).toBeTruthy(); + expect(await screen.findByText('PROTECTED PAGE')).toBeTruthy(); }); - it('redirects disallowed roles (GUEST/VIEWER/USER) away from /admin', () => { + it('redirects disallowed roles (GUEST/VIEWER/USER) away from /admin', async () => { for (const r of ['GUEST', 'VIEWER', 'USER']) { - const { unmount } = renderGuarded(r, canAccessAdmin); + renderGuarded(r, canAccessAdmin); + // resolves to HOME; PROTECTED PAGE must never render. + expect(await screen.findByText('HOME')).toBeTruthy(); expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); - expect(screen.getByText('HOME')).toBeTruthy(); - unmount(); + cleanup(); } }); - it('redirects when unauthenticated (no user)', () => { + it('redirects when unauthenticated (no user)', async () => { renderGuarded(undefined, canAccessAdmin); + expect(await screen.findByText('HOME')).toBeTruthy(); expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); - expect(screen.getByText('HOME')).toBeTruthy(); }); - it('backend guard: blocks GUEST/VIEWER, allows USER/ADMIN', () => { - const { unmount } = renderGuarded('GUEST', canAccessBackend); + it('backend guard: blocks GUEST/VIEWER, allows USER/ADMIN', async () => { + renderGuarded('GUEST', canAccessBackend); + expect(await screen.findByText('HOME')).toBeTruthy(); expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); - unmount(); + cleanup(); renderGuarded('USER', canAccessBackend); - expect(screen.getByText('PROTECTED PAGE')).toBeTruthy(); + expect(await screen.findByText('PROTECTED PAGE')).toBeTruthy(); }); }); diff --git a/packages/web/src/pages/Admin.tsx b/packages/web/src/pages/Admin.tsx index bd6e0ca8..d81192de 100644 --- a/packages/web/src/pages/Admin.tsx +++ b/packages/web/src/pages/Admin.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; import { Users, Database, Shield, Download, Upload, Settings2, RefreshCw, AlertCircle, Lock, Key, Globe, CheckCircle, XCircle, AlertTriangle, FileText, Calendar, Server, Network, Copy, Eye, EyeOff } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { canAccessAdmin } from '../lib/roleAccess'; import { AdminUserManagement } from '../components/AdminUserManagement'; import { CustomDropdown } from '../components/CustomDropdown'; import { APP_VERSION } from '../utils/version'; @@ -12,17 +14,13 @@ export function Admin() { const { currentUser } = useAuth(); const [activeTab, setActiveTab] = useState('users'); - // Redirect if not ADMIN - if (currentUser?.role !== 'ADMIN') { - return ( -
-
- -

Access Denied

-

Only ADMIN users can access the Admin panel.

-
-
- ); + // Hard lockout for non-admins: redirect away rather than render any admin + // chrome/tabs. This is defense-in-depth — the /admin route is already gated by + // and the Worker rejects admin operations + // server-side — but the page also fails closed on its own so admin UI can + // never mount for a non-admin even if the route guard is ever changed. + if (!canAccessAdmin(currentUser?.role)) { + return ; } const tabs = [