- Implement Next.js middleware for route protection - Create Zustand auth store for web (similar to mobile) - Add comprehensive tests for middleware and auth store - Protect authenticated routes (/dashboard, /profile) - Redirect unauthenticated users to /login - Redirect authenticated users from auth routes to /dashboard - Handle session expiration with 401 callback - Set access token cookie for middleware - All tests passing (105 tests total)
318 lines
9.5 KiB
TypeScript
318 lines
9.5 KiB
TypeScript
/**
|
|
* @jest-environment jsdom
|
|
*/
|
|
import { useAuthStore, initAuthStore } from '../stores/authStore';
|
|
import api from '../lib/api';
|
|
|
|
// Mock the API module
|
|
jest.mock('../lib/api', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
checkEmail: jest.fn(),
|
|
requestOTP: jest.fn(),
|
|
verifyOTP: jest.fn(),
|
|
logout: jest.fn(),
|
|
isAuthenticated: jest.fn(),
|
|
getStoredUser: jest.fn(),
|
|
getToken: jest.fn(),
|
|
},
|
|
setOnUnauthorizedCallback: jest.fn(),
|
|
}));
|
|
|
|
describe('Auth Store', () => {
|
|
beforeEach(() => {
|
|
// Reset store to initial state
|
|
useAuthStore.setState({
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: true,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
|
|
// Clear all mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Clear cookies
|
|
document.cookie = 'accessToken=; path=/; max-age=0';
|
|
});
|
|
|
|
describe('Initial State', () => {
|
|
it('should have correct initial state', () => {
|
|
const state = useAuthStore.getState();
|
|
|
|
expect(state.user).toBeNull();
|
|
expect(state.isLoading).toBe(false);
|
|
expect(state.isInitializing).toBe(true);
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('checkEmail', () => {
|
|
it('should successfully check if email exists', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({
|
|
ok: true,
|
|
data: { exists: true, name: 'John Doe' },
|
|
});
|
|
|
|
const result = await useAuthStore.getState().checkEmail('test@example.com');
|
|
|
|
expect(result).toEqual({ exists: true, name: 'John Doe' });
|
|
expect(api.checkEmail).toHaveBeenCalledWith('test@example.com');
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
expect(useAuthStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it('should handle email check failure', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Network error' },
|
|
});
|
|
|
|
const result = await useAuthStore.getState().checkEmail('test@example.com');
|
|
|
|
expect(result).toEqual({ exists: false });
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
});
|
|
|
|
it('should handle network error during email check', async () => {
|
|
(api.checkEmail as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await useAuthStore.getState().checkEmail('test@example.com');
|
|
|
|
expect(result).toEqual({ exists: false });
|
|
expect(useAuthStore.getState().error).toBe('Network error. Please check your connection.');
|
|
});
|
|
});
|
|
|
|
describe('requestOtp', () => {
|
|
it('should successfully request OTP', async () => {
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({
|
|
ok: true,
|
|
data: { message: 'OTP sent' },
|
|
});
|
|
|
|
const result = await useAuthStore.getState().requestOtp('test@example.com');
|
|
|
|
expect(result).toEqual({ success: true });
|
|
expect(api.requestOTP).toHaveBeenCalledWith('test@example.com');
|
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
|
expect(useAuthStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it('should handle OTP request failure', async () => {
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Failed to send OTP' },
|
|
});
|
|
|
|
const result = await useAuthStore.getState().requestOtp('test@example.com');
|
|
|
|
expect(result).toEqual({ success: false });
|
|
expect(useAuthStore.getState().error).toBe('Failed to send verification code. Please try again.');
|
|
});
|
|
});
|
|
|
|
describe('verifyOtp', () => {
|
|
it('should successfully verify OTP and authenticate user', async () => {
|
|
(api.verifyOTP as jest.Mock).mockResolvedValue({
|
|
ok: true,
|
|
data: {
|
|
token: 'mock-jwt-token',
|
|
user: {
|
|
id: '123',
|
|
email: 'test@example.com',
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await useAuthStore.getState().verifyOtp('test@example.com', '123456');
|
|
|
|
expect(result).toBe(true);
|
|
expect(api.verifyOTP).toHaveBeenCalledWith('test@example.com', '123456');
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.user).toEqual({
|
|
user_id: 123,
|
|
email: 'test@example.com',
|
|
firstName: 'John',
|
|
lastName: 'Doe',
|
|
max_role: 'USER',
|
|
privileges: '',
|
|
});
|
|
expect(state.error).toBeNull();
|
|
expect(state.isLoading).toBe(false);
|
|
|
|
// Check that cookie was set
|
|
expect(document.cookie).toContain('accessToken=mock-jwt-token');
|
|
});
|
|
|
|
it('should handle invalid OTP code', async () => {
|
|
(api.verifyOTP as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Invalid code' },
|
|
});
|
|
|
|
const result = await useAuthStore.getState().verifyOtp('test@example.com', '000000');
|
|
|
|
expect(result).toBe(false);
|
|
expect(useAuthStore.getState().error).toBe('Invalid verification code. Please try again.');
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
});
|
|
|
|
it('should handle network error during OTP verification', async () => {
|
|
(api.verifyOTP as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await useAuthStore.getState().verifyOtp('test@example.com', '123456');
|
|
|
|
expect(result).toBe(false);
|
|
expect(useAuthStore.getState().error).toBe('Network error');
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('should successfully logout user', async () => {
|
|
// Set up authenticated state
|
|
useAuthStore.setState({
|
|
user: { user_id: 123, email: 'test@example.com' },
|
|
isAuthenticated: true,
|
|
});
|
|
|
|
// Set cookie
|
|
document.cookie = 'accessToken=test-token; path=/';
|
|
|
|
(api.logout as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
// Mock window.location.href
|
|
delete (window as any).location;
|
|
(window as any).location = { href: '' };
|
|
|
|
await useAuthStore.getState().logout();
|
|
|
|
expect(api.logout).toHaveBeenCalled();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.user).toBeNull();
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.error).toBeNull();
|
|
|
|
// Check that cookie was cleared
|
|
expect(document.cookie).not.toContain('accessToken=test-token');
|
|
});
|
|
});
|
|
|
|
describe('_checkAuth', () => {
|
|
it('should set authenticated state when user is logged in', async () => {
|
|
(api.isAuthenticated as jest.Mock).mockResolvedValue(true);
|
|
(api.getStoredUser as jest.Mock).mockResolvedValue({
|
|
user_id: 123,
|
|
email: 'test@example.com',
|
|
firstName: 'John',
|
|
});
|
|
(api.getToken as jest.Mock).mockResolvedValue('mock-token');
|
|
|
|
await useAuthStore.getState()._checkAuth();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.user).toEqual({
|
|
user_id: 123,
|
|
email: 'test@example.com',
|
|
firstName: 'John',
|
|
});
|
|
expect(state.isInitializing).toBe(false);
|
|
expect(document.cookie).toContain('accessToken=mock-token');
|
|
});
|
|
|
|
it('should set unauthenticated state when user is not logged in', async () => {
|
|
(api.isAuthenticated as jest.Mock).mockResolvedValue(false);
|
|
|
|
await useAuthStore.getState()._checkAuth();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.user).toBeNull();
|
|
expect(state.isInitializing).toBe(false);
|
|
expect(document.cookie).not.toContain('accessToken');
|
|
});
|
|
|
|
it('should handle errors during auth check', async () => {
|
|
(api.isAuthenticated as jest.Mock).mockRejectedValue(new Error('Auth check failed'));
|
|
|
|
await useAuthStore.getState()._checkAuth();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(false);
|
|
expect(state.user).toBeNull();
|
|
expect(state.error).toBe('Failed to check authentication');
|
|
});
|
|
});
|
|
|
|
describe('clearError', () => {
|
|
it('should clear error state', () => {
|
|
useAuthStore.setState({ error: 'Some error' });
|
|
|
|
useAuthStore.getState().clearError();
|
|
|
|
expect(useAuthStore.getState().error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('refreshAuth', () => {
|
|
it('should call _checkAuth', async () => {
|
|
(api.isAuthenticated as jest.Mock).mockResolvedValue(false);
|
|
|
|
await useAuthStore.getState().refreshAuth();
|
|
|
|
expect(api.isAuthenticated).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('updateUser', () => {
|
|
it('should update user profile in state', () => {
|
|
useAuthStore.setState({
|
|
user: {
|
|
user_id: 123,
|
|
email: 'test@example.com',
|
|
firstName: 'John',
|
|
lastName: 'Doe',
|
|
},
|
|
});
|
|
|
|
useAuthStore.getState().updateUser({ firstName: 'Jane' });
|
|
|
|
expect(useAuthStore.getState().user).toEqual({
|
|
user_id: 123,
|
|
email: 'test@example.com',
|
|
firstName: 'Jane',
|
|
lastName: 'Doe',
|
|
});
|
|
});
|
|
|
|
it('should do nothing if user is null', () => {
|
|
useAuthStore.setState({ user: null });
|
|
|
|
useAuthStore.getState().updateUser({ firstName: 'Jane' });
|
|
|
|
expect(useAuthStore.getState().user).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('initAuthStore', () => {
|
|
it('should initialize store and set callbacks', async () => {
|
|
(api.isAuthenticated as jest.Mock).mockResolvedValue(false);
|
|
|
|
initAuthStore();
|
|
|
|
// Wait for async _checkAuth to complete
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(api.isAuthenticated).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|