From 819f3ba96369533a07d5abdf79f3a1e028c2be8e Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Sat, 1 Nov 2025 01:31:22 +0100 Subject: [PATCH] Add tests for `useAuth` hooks and `AuthGuard` component; Update `.gitignore` - Implemented comprehensive tests for `useAuth` hooks (`useIsAuthenticated`, `useCurrentUser`, and `useIsAdmin`) with mock states and coverage for edge cases. - Added tests for `AuthGuard` to validate route protection, admin access control, loading states, and use of fallback components. - Updated `.gitignore` to exclude `coverage.json`. --- .gitignore | 1 + .../tests/components/auth/AuthGuard.test.tsx | 359 ++++++++++++++++++ frontend/tests/lib/api/hooks/useAuth.test.tsx | 222 +++++++++++ 3 files changed, 582 insertions(+) create mode 100644 frontend/tests/components/auth/AuthGuard.test.tsx create mode 100644 frontend/tests/lib/api/hooks/useAuth.test.tsx diff --git a/.gitignore b/.gitignore index 8a2b8af..e03a68e 100755 --- a/.gitignore +++ b/.gitignore @@ -174,6 +174,7 @@ htmlcov/ .nox/ .coverage .coverage.* +coverage.json .cache nosetests.xml coverage.xml diff --git a/frontend/tests/components/auth/AuthGuard.test.tsx b/frontend/tests/components/auth/AuthGuard.test.tsx new file mode 100644 index 0000000..e0d554f --- /dev/null +++ b/frontend/tests/components/auth/AuthGuard.test.tsx @@ -0,0 +1,359 @@ +/** + * Tests for AuthGuard component + * Security-critical: Route protection and access control + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthGuard } from '@/components/auth/AuthGuard'; + +// Mock Next.js navigation +const mockPush = jest.fn(); +const mockPathname = '/protected'; + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), + usePathname: () => mockPathname, +})); + +// Mock auth store +let mockAuthState: { + isAuthenticated: boolean; + isLoading: boolean; + user: any; +} = { + isAuthenticated: false, + isLoading: false, + user: null, +}; + +jest.mock('@/stores/authStore', () => ({ + useAuthStore: () => mockAuthState, +})); + +// Mock useMe hook +let mockMeState: { + isLoading: boolean; + data: any; +} = { + isLoading: false, + data: null, +}; + +jest.mock('@/lib/api/hooks/useAuth', () => ({ + useMe: () => mockMeState, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; + +describe('AuthGuard', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset to default unauthenticated state + mockAuthState = { + isAuthenticated: false, + isLoading: false, + user: null, + }; + mockMeState = { + isLoading: false, + data: null, + }; + }); + + describe('Loading States', () => { + it('shows loading spinner when auth is loading', () => { + mockAuthState = { + isAuthenticated: false, + isLoading: true, + user: null, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('shows loading spinner when user data is loading', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: null, + }; + mockMeState = { + isLoading: true, + data: null, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('shows custom fallback when provided', () => { + mockAuthState = { + isAuthenticated: false, + isLoading: true, + user: null, + }; + + render( + Please wait...}> +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Please wait...')).toBeInTheDocument(); + // Default spinner should not be shown + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + + describe('Authentication', () => { + it('redirects to login when not authenticated', async () => { + mockAuthState = { + isAuthenticated: false, + isLoading: false, + user: null, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/login?returnUrl=%2Fprotected'); + }); + + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('renders children when authenticated', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Protected Content')).toBeInTheDocument(); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + describe('Admin Access Control', () => { + it('renders children for admin user when requireAdmin is true', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: { + id: '1', + email: 'admin@example.com', + first_name: 'Admin', + last_name: 'User', + is_active: true, + is_superuser: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + render( + +
Admin Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Admin Content')).toBeInTheDocument(); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('redirects non-admin user when requireAdmin is true', async () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Regular', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + render( + +
Admin Content
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + + expect(screen.queryByText('Admin Content')).not.toBeInTheDocument(); + }); + + it('does not redirect regular user when requireAdmin is false', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Regular', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + render( + +
User Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText('User Content')).toBeInTheDocument(); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + describe('Return URL Preservation', () => { + it('preserves current path in returnUrl when redirecting', async () => { + mockAuthState = { + isAuthenticated: false, + isLoading: false, + user: null, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + expect.stringContaining('returnUrl=%2Fprotected') + ); + }); + }); + }); + + describe('Integration with useMe', () => { + it('shows loading while useMe fetches user data', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: null, + }; + mockMeState = { + isLoading: true, + data: null, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('renders children after useMe completes', () => { + mockAuthState = { + isAuthenticated: true, + isLoading: false, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + mockMeState = { + isLoading: false, + data: { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + render( + +
Protected Content
+
, + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Protected Content')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/lib/api/hooks/useAuth.test.tsx b/frontend/tests/lib/api/hooks/useAuth.test.tsx new file mode 100644 index 0000000..3a962ad --- /dev/null +++ b/frontend/tests/lib/api/hooks/useAuth.test.tsx @@ -0,0 +1,222 @@ +/** + * Tests for useAuth hooks + * Note: Full API integration tests require MSW (planned for Phase 9) + * These tests cover hook setup, types, and basic integration + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + useIsAuthenticated, + useCurrentUser, + useIsAdmin, +} from '@/lib/api/hooks/useAuth'; + +// Mock auth store +let mockAuthState: { + isAuthenticated: boolean; + user: any; + accessToken: string | null; + refreshToken: string | null; +} = { + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, +}; + +jest.mock('@/stores/authStore', () => ({ + useAuthStore: (selector?: (state: any) => any) => { + if (selector) { + return selector(mockAuthState); + } + return mockAuthState; + }, +})); + +// Mock router +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; + +describe('useAuth Hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuthState = { + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, + }; + }); + + describe('useIsAuthenticated', () => { + it('returns false when not authenticated', () => { + mockAuthState = { + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, + }; + + const { result } = renderHook(() => useIsAuthenticated(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe(false); + }); + + it('returns true when authenticated', () => { + mockAuthState = { + isAuthenticated: true, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + accessToken: 'test-token', + refreshToken: 'test-refresh', + }; + + const { result } = renderHook(() => useIsAuthenticated(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe(true); + }); + }); + + describe('useCurrentUser', () => { + it('returns null when not authenticated', () => { + mockAuthState = { + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, + }; + + const { result } = renderHook(() => useCurrentUser(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeNull(); + }); + + it('returns user when authenticated', () => { + const mockUser = { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }; + + mockAuthState = { + isAuthenticated: true, + user: mockUser, + accessToken: 'test-token', + refreshToken: 'test-refresh', + }; + + const { result } = renderHook(() => useCurrentUser(), { + wrapper: createWrapper(), + }); + + expect(result.current).toEqual(mockUser); + }); + }); + + describe('useIsAdmin', () => { + it('returns false when not authenticated', () => { + mockAuthState = { + isAuthenticated: false, + user: null, + accessToken: null, + refreshToken: null, + }; + + const { result } = renderHook(() => useIsAdmin(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe(false); + }); + + it('returns false for regular user', () => { + mockAuthState = { + isAuthenticated: true, + user: { + id: '1', + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + accessToken: 'test-token', + refreshToken: 'test-refresh', + }; + + const { result } = renderHook(() => useIsAdmin(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe(false); + }); + + it('returns true for admin user', () => { + mockAuthState = { + isAuthenticated: true, + user: { + id: '1', + email: 'admin@example.com', + first_name: 'Admin', + last_name: 'User', + is_active: true, + is_superuser: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + accessToken: 'test-token', + refreshToken: 'test-refresh', + }; + + const { result } = renderHook(() => useIsAdmin(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe(true); + }); + }); + + // Note: Mutation hooks (useLogin, useRegister, etc.) require MSW for full testing + // These will be tested in Phase 9 with proper API mocking + // For now, we've tested the convenience hooks which improve coverage significantly +});