Files
syndarix/frontend/tests/lib/stores/authStore.test.ts
Felipe Cardoso 96df7edf88 Refactor useAuth hook, settings components, and docs for formatting and readability improvements
- Consolidated multi-line arguments into single lines where appropriate in `useAuth`.
- Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`).
- Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`).
- Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
2025-11-10 11:03:45 +01:00

469 lines
15 KiB
TypeScript

/**
* Tests for auth store
*/
import { useAuthStore, type User } from '@/lib/stores/authStore';
import * as storage from '@/lib/auth/storage';
// Mock storage module
jest.mock('@/lib/auth/storage');
/**
* Helper to create mock user object with all required fields
*/
function createMockUser(overrides: Partial<User> = {}): User {
return {
id: 'user-123',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User',
phone_number: null,
is_active: true,
is_superuser: false,
created_at: new Date().toISOString(),
updated_at: null,
...overrides,
};
}
describe('Auth Store', () => {
beforeEach(() => {
// Reset store state
useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
tokenExpiresAt: null,
});
jest.clearAllMocks();
// Reset storage mocks to default successful implementations
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
(storage.getTokens as jest.Mock).mockResolvedValue(null);
(storage.clearTokens as jest.Mock).mockResolvedValue(undefined);
});
describe('User validation', () => {
it('should reject empty string user ID', async () => {
const invalidUser = createMockUser({ id: '' });
await expect(
useAuthStore.getState().setAuth(invalidUser, 'valid.access.token', 'valid.refresh.token')
).rejects.toThrow('Invalid user object');
});
it('should reject whitespace-only user ID', async () => {
const invalidUser = createMockUser({ id: ' ' });
await expect(
useAuthStore.getState().setAuth(invalidUser, 'valid.access.token', 'valid.refresh.token')
).rejects.toThrow('Invalid user object');
});
it('should reject empty string email', async () => {
const invalidUser = createMockUser({ email: '' });
await expect(
useAuthStore.getState().setAuth(invalidUser, 'valid.access.token', 'valid.refresh.token')
).rejects.toThrow('Invalid user object');
});
it('should reject non-string user ID', async () => {
const invalidUser = createMockUser({ id: 123 as any });
await expect(
useAuthStore.getState().setAuth(
invalidUser as any, // Testing runtime validation with invalid type
'valid.access.token',
'valid.refresh.token'
)
).rejects.toThrow('Invalid user object');
});
it('should accept valid user', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
await expect(
useAuthStore
.getState()
.setAuth(validUser, 'header.payload.signature', 'header.payload.signature')
).resolves.not.toThrow();
const state = useAuthStore.getState();
expect(state.user).toEqual(validUser);
expect(state.isAuthenticated).toBe(true);
});
});
describe('Token validation', () => {
it('should reject invalid JWT format (not 3 parts)', async () => {
const validUser = createMockUser();
await expect(
useAuthStore.getState().setAuth(
validUser,
'invalid.token', // Only 2 parts
'header.payload.signature'
)
).rejects.toThrow('Invalid token format');
});
it('should reject JWT with empty parts', async () => {
const validUser = createMockUser();
await expect(
useAuthStore.getState().setAuth(
validUser,
'header..signature', // Empty payload
'header.payload.signature'
)
).rejects.toThrow('Invalid token format');
});
it('should accept valid JWT format', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
await expect(
useAuthStore
.getState()
.setAuth(validUser, 'header.payload.signature', 'header.payload.signature')
).resolves.not.toThrow();
});
});
describe('Token expiry calculation', () => {
it('should reject negative expiresIn', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
// Should not throw, but should use default
await useAuthStore.getState().setAuth(
validUser,
'header.payload.signature',
'header.payload.signature',
-1 // Negative!
);
const state = useAuthStore.getState();
const expectedExpiry = Date.now() + 900 * 1000; // Should use default 900s
// Allow 1 second tolerance
expect(state.tokenExpiresAt).toBeGreaterThan(expectedExpiry - 1000);
expect(state.tokenExpiresAt).toBeLessThan(expectedExpiry + 1000);
});
it('should reject zero expiresIn', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
await useAuthStore.getState().setAuth(
validUser,
'header.payload.signature',
'header.payload.signature',
0 // Zero!
);
const state = useAuthStore.getState();
const expectedExpiry = Date.now() + 900 * 1000;
expect(state.tokenExpiresAt).toBeGreaterThan(expectedExpiry - 1000);
expect(state.tokenExpiresAt).toBeLessThan(expectedExpiry + 1000);
});
it('should reject excessively large expiresIn', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
await useAuthStore.getState().setAuth(
validUser,
'header.payload.signature',
'header.payload.signature',
99999999 // Way too large!
);
const state = useAuthStore.getState();
const expectedExpiry = Date.now() + 900 * 1000; // Should use default
expect(state.tokenExpiresAt).toBeGreaterThan(expectedExpiry - 1000);
expect(state.tokenExpiresAt).toBeLessThan(expectedExpiry + 1000);
});
it('should accept valid expiresIn', async () => {
const validUser = createMockUser();
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
await useAuthStore.getState().setAuth(
validUser,
'header.payload.signature',
'header.payload.signature',
3600 // 1 hour
);
const state = useAuthStore.getState();
const expectedExpiry = Date.now() + 3600 * 1000;
expect(state.tokenExpiresAt).toBeGreaterThan(expectedExpiry - 1000);
expect(state.tokenExpiresAt).toBeLessThan(expectedExpiry + 1000);
});
});
describe('isTokenExpired', () => {
it('should return true when no expiry set', () => {
expect(useAuthStore.getState().isTokenExpired()).toBe(true);
});
it('should return true when token is expired', () => {
useAuthStore.setState({
tokenExpiresAt: Date.now() - 1000, // 1 second ago
});
expect(useAuthStore.getState().isTokenExpired()).toBe(true);
});
it('should return false when token is still valid', () => {
useAuthStore.setState({
tokenExpiresAt: Date.now() + 10000, // 10 seconds from now
});
expect(useAuthStore.getState().isTokenExpired()).toBe(false);
});
});
describe('clearAuth', () => {
it('should clear all auth state', async () => {
(storage.saveTokens as jest.Mock).mockResolvedValue(undefined);
(storage.clearTokens as jest.Mock).mockResolvedValue(undefined);
// First set auth
const validUser = createMockUser();
await useAuthStore
.getState()
.setAuth(validUser, 'header.payload.signature', 'header.payload.signature');
expect(useAuthStore.getState().isAuthenticated).toBe(true);
// Then clear
await useAuthStore.getState().clearAuth();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.tokenExpiresAt).toBeNull();
expect(storage.clearTokens).toHaveBeenCalled();
});
it('should not throw if clearTokens fails', async () => {
(storage.clearTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
await expect(useAuthStore.getState().clearAuth()).resolves.not.toThrow();
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
});
});
describe('setTokens', () => {
it('should update tokens while preserving user state', async () => {
// First set initial auth with user
await useAuthStore
.getState()
.setAuth(createMockUser({ id: 'user-1' }), 'old.access.token', 'old.refresh.token');
const oldUser = useAuthStore.getState().user;
// Now update just the tokens
await useAuthStore.getState().setTokens('new.access.token', 'new.refresh.token', 900);
const state = useAuthStore.getState();
expect(state.accessToken).toBe('new.access.token');
expect(state.refreshToken).toBe('new.refresh.token');
expect(state.user).toBe(oldUser); // User should remain unchanged
expect(state.tokenExpiresAt).toBeGreaterThan(Date.now());
});
it('should reject invalid access token in setTokens', async () => {
await expect(
useAuthStore.getState().setTokens('invalid', 'valid.refresh.token', 900)
).rejects.toThrow('Invalid token format');
});
it('should reject invalid refresh token in setTokens', async () => {
await expect(
useAuthStore.getState().setTokens('valid.access.token', 'invalid', 900)
).rejects.toThrow('Invalid token format');
});
it('should throw if storage fails in setTokens', async () => {
(storage.saveTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
await expect(
useAuthStore.getState().setTokens('valid.access.token', 'valid.refresh.token', 900)
).rejects.toThrow();
});
});
describe('setUser', () => {
it('should update user while preserving auth state', async () => {
// First set initial auth
await useAuthStore
.getState()
.setAuth(createMockUser({ id: 'user-1' }), 'valid.access.token', 'valid.refresh.token');
const oldToken = useAuthStore.getState().accessToken;
// Update just the user
const newUser = createMockUser({
id: 'user-1',
email: 'updated@example.com',
is_superuser: true,
});
useAuthStore.getState().setUser(newUser);
const state = useAuthStore.getState();
expect(state.user).toEqual(newUser);
expect(state.accessToken).toBe(oldToken); // Tokens unchanged
});
it('should reject null user', () => {
expect(() => {
useAuthStore.getState().setUser(null as any);
}).toThrow('Invalid user object');
});
it('should reject user with empty id', () => {
expect(() => {
useAuthStore.getState().setUser(createMockUser({ id: '' }));
}).toThrow('Invalid user object');
});
it('should reject user with whitespace-only id', () => {
expect(() => {
useAuthStore.getState().setUser(createMockUser({ id: ' ' }));
}).toThrow('Invalid user object');
});
it('should reject user with non-string email', () => {
expect(() => {
useAuthStore.getState().setUser(createMockUser({ id: 'user-1', email: 123 as any }));
}).toThrow('Invalid user object');
});
});
describe('loadAuthFromStorage', () => {
it('should load valid tokens from storage', async () => {
const mockTokens = {
accessToken: 'valid.access.token',
refreshToken: 'valid.refresh.token',
};
(storage.getTokens as jest.Mock).mockResolvedValue(mockTokens);
await useAuthStore.getState().loadAuthFromStorage();
expect(useAuthStore.getState().accessToken).toBe(mockTokens.accessToken);
expect(useAuthStore.getState().refreshToken).toBe(mockTokens.refreshToken);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().isLoading).toBe(false);
});
it('should set isLoading to false when no tokens found', async () => {
(storage.getTokens as jest.Mock).mockResolvedValue(null);
await useAuthStore.getState().loadAuthFromStorage();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isLoading).toBe(false);
});
it('should ignore invalid tokens from storage', async () => {
const invalidTokens = {
accessToken: 'invalid-token', // Not in JWT format
refreshToken: 'valid.refresh.token',
};
(storage.getTokens as jest.Mock).mockResolvedValue(invalidTokens);
await useAuthStore.getState().loadAuthFromStorage();
// Should not set auth state with invalid tokens
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().accessToken).toBeNull();
expect(useAuthStore.getState().isLoading).toBe(false);
});
it('should handle storage errors gracefully', async () => {
(storage.getTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await useAuthStore.getState().loadAuthFromStorage();
expect(useAuthStore.getState().isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('initializeAuth', () => {
it('should call loadAuthFromStorage', async () => {
const mockTokens = {
accessToken: 'valid.access.token',
refreshToken: 'valid.refresh.token',
};
(storage.getTokens as jest.Mock).mockResolvedValue(mockTokens);
const { initializeAuth } = await import('@/lib/stores/authStore');
await initializeAuth();
expect(storage.getTokens).toHaveBeenCalled();
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('should not throw on error and log error', async () => {
(storage.getTokens as jest.Mock).mockRejectedValue(new Error('Init error'));
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const { initializeAuth } = await import('@/lib/stores/authStore');
await expect(initializeAuth()).resolves.not.toThrow();
// Verify error was logged by loadAuthFromStorage (which initializeAuth calls)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load auth from storage:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe('Storage error handling', () => {
it('should handle saveTokens failure in setAuth', async () => {
const mockUser = createMockUser();
(storage.saveTokens as jest.Mock).mockRejectedValue(new Error('Storage error'));
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await expect(
useAuthStore.getState().setAuth(mockUser, 'valid.access.token', 'valid.refresh.token')
).rejects.toThrow('Storage error');
// Verify error was logged before throwing
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to save auth state:', expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
});