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`.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -174,6 +174,7 @@ htmlcov/
|
|||||||
.nox/
|
.nox/
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
|
coverage.json
|
||||||
.cache
|
.cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
|
|||||||
359
frontend/tests/components/auth/AuthGuard.test.tsx
Normal file
359
frontend/tests/components/auth/AuthGuard.test.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows custom fallback when provided', () => {
|
||||||
|
mockAuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthGuard fallback={<div>Please wait...</div>}>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard requireAdmin>
|
||||||
|
<div>Admin Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard requireAdmin>
|
||||||
|
<div>Admin Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard requireAdmin={false}>
|
||||||
|
<div>User Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ 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(
|
||||||
|
<AuthGuard>
|
||||||
|
<div>Protected Content</div>
|
||||||
|
</AuthGuard>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
222
frontend/tests/lib/api/hooks/useAuth.test.tsx
Normal file
222
frontend/tests/lib/api/hooks/useAuth.test.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user