AdminGuard.test.tsx
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 4 import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; 5 import { render, screen, waitFor, cleanup } from '@testing-library/react'; 6 import { http, HttpResponse } from 'msw'; 7 import { setupServer } from 'msw/node'; 8 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 9 import { AdminGuard } from '../AdminGuard'; 10 import { AuthContext, type AuthContextValue } from '../../../auth/AuthContext'; 11 12 const server = setupServer(); 13 14 beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 15 afterEach(() => { cleanup(); server.resetHandlers(); }); 16 afterAll(() => server.close()); 17 18 function makeAuth(overrides: Partial<AuthContextValue> = {}): AuthContextValue { 19 return { 20 isAuthenticated: true, 21 authEnabled: true, 22 user: null, 23 roles: [], 24 getAccessToken: async () => null, 25 login: async () => {}, 26 logout: async () => {}, 27 ...overrides, 28 }; 29 } 30 31 function renderGuard(auth: AuthContextValue) { 32 const queryClient = new QueryClient({ 33 defaultOptions: { queries: { retry: false } }, 34 }); 35 return render( 36 <AuthContext.Provider value={auth}> 37 <QueryClientProvider client={queryClient}> 38 <AdminGuard> 39 <div data-testid="protected-content">Admin Content</div> 40 </AdminGuard> 41 </QueryClientProvider> 42 </AuthContext.Provider>, 43 ); 44 } 45 46 describe('AdminGuard', () => { 47 it('renders children when auth is disabled', () => { 48 renderGuard(makeAuth({ authEnabled: false })); 49 expect(screen.getByTestId('protected-content')).toBeInTheDocument(); 50 }); 51 52 it('renders children when whoami returns is_admin: true', async () => { 53 server.use( 54 http.get('http://localhost:8000/admin/roles/whoami', () => 55 HttpResponse.json({ is_admin: true, email: 'admin@test.com', roles: ['operator'] }), 56 ), 57 ); 58 renderGuard(makeAuth()); 59 await waitFor(() => { 60 expect(screen.getByTestId('protected-content')).toBeInTheDocument(); 61 }); 62 }); 63 64 it('shows access restricted when whoami returns is_admin: false', async () => { 65 server.use( 66 http.get('http://localhost:8000/admin/roles/whoami', () => 67 HttpResponse.json({ is_admin: false }), 68 ), 69 ); 70 renderGuard(makeAuth()); 71 await waitFor(() => { 72 expect(screen.getByText('Access Restricted')).toBeInTheDocument(); 73 }); 74 expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); 75 }); 76 77 it('shows access restricted when whoami returns 403', async () => { 78 server.use( 79 http.get('http://localhost:8000/admin/roles/whoami', () => 80 HttpResponse.json({ detail: 'Forbidden' }, { status: 403 }), 81 ), 82 ); 83 renderGuard(makeAuth()); 84 await waitFor(() => { 85 expect(screen.getByText('Access Restricted')).toBeInTheDocument(); 86 }); 87 }); 88 89 it('shows loading spinner while whoami is pending', () => { 90 server.use( 91 http.get('http://localhost:8000/admin/roles/whoami', () => 92 new Promise(() => {}), // never resolves 93 ), 94 ); 95 renderGuard(makeAuth()); 96 expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); 97 expect(screen.queryByText('Access Restricted')).not.toBeInTheDocument(); 98 }); 99 100 it('shows session-not-ready message (not Access Restricted) when whoami returns 401', async () => { 101 server.use( 102 http.get('http://localhost:8000/admin/roles/whoami', () => 103 HttpResponse.json({ detail: 'Missing Bearer token' }, { status: 401 }), 104 ), 105 ); 106 renderGuard(makeAuth()); 107 await waitFor(() => { 108 expect(screen.getByText('Session not ready')).toBeInTheDocument(); 109 }, { timeout: 6000 }); // 2 retries × 1500ms retryDelay = 3000ms minimum 110 // Must NOT show "Access Restricted" for auth errors 111 expect(screen.queryByText('Access Restricted')).not.toBeInTheDocument(); 112 expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); 113 // Retry button is present 114 expect(screen.getByRole('button', { name: /Retry/i })).toBeInTheDocument(); 115 }); 116 });