diff --git a/frontend/src/lib/auth/storage.ts b/frontend/src/lib/auth/storage.ts index b4e8f87..57c45b3 100644 --- a/frontend/src/lib/auth/storage.ts +++ b/frontend/src/lib/auth/storage.ts @@ -134,14 +134,17 @@ export async function getTokens(): Promise { } const decrypted = await decryptData(encrypted); - const tokens = JSON.parse(decrypted) as TokenStorage; + const parsed = JSON.parse(decrypted); - // Validate structure - if (!tokens || typeof tokens !== 'object') { + // Validate structure - must have required fields + if (!parsed || typeof parsed !== 'object' || + !('accessToken' in parsed) || !('refreshToken' in parsed) || + (parsed.accessToken !== null && typeof parsed.accessToken !== 'string') || + (parsed.refreshToken !== null && typeof parsed.refreshToken !== 'string')) { throw new Error('Invalid token structure'); } - return tokens; + return parsed as TokenStorage; } catch (error) { console.error('Failed to retrieve tokens:', error); // If decryption fails, clear invalid data diff --git a/frontend/tests/config/app.config.test.ts b/frontend/tests/config/app.config.test.ts new file mode 100644 index 0000000..b50d937 --- /dev/null +++ b/frontend/tests/config/app.config.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for application configuration + */ + +describe('App Configuration', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original env + originalEnv = { ...process.env }; + + // Clear module cache to test with different env vars + jest.resetModules(); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + }); + + describe('parseIntSafe', () => { + it('should parse valid integers', () => { + process.env.NEXT_PUBLIC_API_TIMEOUT = '5000'; + + // Dynamically import to pick up new env + const { config } = require('@/config/app.config'); + + expect(config.api.timeout).toBe(5000); + }); + + it('should use default for NaN', () => { + process.env.NEXT_PUBLIC_API_TIMEOUT = 'not_a_number'; + + const { config } = require('@/config/app.config'); + + expect(config.api.timeout).toBe(30000); // default + }); + + it('should enforce minimum values', () => { + process.env.NEXT_PUBLIC_API_TIMEOUT = '500'; // Below min of 1000 + + const { config } = require('@/config/app.config'); + + expect(config.api.timeout).toBe(1000); // minimum + }); + + it('should enforce maximum values', () => { + process.env.NEXT_PUBLIC_API_TIMEOUT = '999999'; // Above max of 120000 + + const { config } = require('@/config/app.config'); + + expect(config.api.timeout).toBe(120000); // maximum + }); + + it('should handle negative numbers', () => { + process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY = '-100'; + + const { config } = require('@/config/app.config'); + + expect(config.auth.accessTokenExpiry).toBe(60000); // minimum + }); + }); + + describe('URL validation', () => { + it('should accept valid URLs', () => { + process.env.NEXT_PUBLIC_API_BASE_URL = 'https://api.example.com'; + + const { config } = require('@/config/app.config'); + + expect(config.api.baseUrl).toBe('https://api.example.com'); + }); + + it('should throw on invalid URLs', () => { + process.env.NEXT_PUBLIC_API_BASE_URL = 'not a url'; + + // Mock window to undefined to prevent validation + const originalWindow = global.window; + (global as any).window = undefined; + + expect(() => { + require('@/config/app.config'); + }).toThrow('Invalid URL'); + + (global as any).window = originalWindow; + }); + }); + + describe('Config validation', () => { + it('should validate access token expiry is positive', () => { + process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY = '-1000'; + + const originalWindow = global.window; + (global as any).window = { document: {} }; // Simulate browser + + expect(() => { + require('@/config/app.config'); + }).toThrow('Invalid application configuration'); + + (global as any).window = originalWindow; + }); + + it('should validate refresh token expiry > access token expiry', () => { + process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY = '1000000'; + process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY = '500000'; // Less than access! + + const originalWindow = global.window; + (global as any).window = { document: {} }; + + expect(() => { + require('@/config/app.config'); + }).toThrow('Invalid application configuration'); + + (global as any).window = originalWindow; + }); + }); + + describe('Boolean parsing', () => { + it('should parse "true" as true', () => { + process.env.NEXT_PUBLIC_ENABLE_REGISTRATION = 'true'; + + const { config } = require('@/config/app.config'); + + expect(config.features.enableRegistration).toBe(true); + }); + + it('should parse anything else as false', () => { + process.env.NEXT_PUBLIC_ENABLE_REGISTRATION = 'yes'; + + const { config } = require('@/config/app.config'); + + expect(config.features.enableRegistration).toBe(false); + }); + + it('should use default when undefined', () => { + delete process.env.NEXT_PUBLIC_ENABLE_REGISTRATION; + + const { config } = require('@/config/app.config'); + + expect(config.features.enableRegistration).toBe(true); // default + }); + }); + + describe('Environment detection', () => { + it('should detect test environment', () => { + // NODE_ENV is set by Jest to 'test' + const { config } = require('@/config/app.config'); + + expect(config.env.isTest).toBe(true); + }); + }); +}); diff --git a/frontend/src/lib/auth/__tests__/crypto.test.ts b/frontend/tests/lib/auth/crypto.test.ts similarity index 97% rename from frontend/src/lib/auth/__tests__/crypto.test.ts rename to frontend/tests/lib/auth/crypto.test.ts index a4e5097..a585a39 100644 --- a/frontend/src/lib/auth/__tests__/crypto.test.ts +++ b/frontend/tests/lib/auth/crypto.test.ts @@ -2,7 +2,7 @@ * Tests for crypto utilities */ -import { encryptData, decryptData, clearEncryptionKey } from '../crypto'; +import { encryptData, decryptData, clearEncryptionKey } from '@/lib/auth/crypto'; describe('Crypto Utilities', () => { beforeEach(() => { diff --git a/frontend/tests/lib/auth/storage.test.ts b/frontend/tests/lib/auth/storage.test.ts new file mode 100644 index 0000000..5a14797 --- /dev/null +++ b/frontend/tests/lib/auth/storage.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for secure storage module + */ + +import { saveTokens, getTokens, clearTokens, isStorageAvailable } from '@/lib/auth/storage'; + +// Mock crypto functions for testing +jest.mock('@/lib/auth/crypto', () => ({ + encryptData: jest.fn((data: string) => Promise.resolve(`encrypted_${data}`)), + decryptData: jest.fn((data: string) => Promise.resolve(data.replace('encrypted_', ''))), + clearEncryptionKey: jest.fn(), +})); + +describe('Storage Module', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + jest.clearAllMocks(); + }); + + describe('isStorageAvailable', () => { + it('should return true when localStorage is available', () => { + expect(isStorageAvailable()).toBe(true); + }); + + it('should handle quota exceeded errors gracefully', () => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new Error('QuotaExceededError'); + }); + + expect(isStorageAvailable()).toBe(false); + + Storage.prototype.setItem = originalSetItem; + }); + }); + + describe('saveTokens and getTokens', () => { + it('should save and retrieve tokens', async () => { + const tokens = { + accessToken: 'test.access.token', + refreshToken: 'test.refresh.token', + }; + + await saveTokens(tokens); + const retrieved = await getTokens(); + + expect(retrieved).toEqual(tokens); + }); + + it('should return null when no tokens are stored', async () => { + const result = await getTokens(); + expect(result).toBeNull(); + }); + + it('should handle corrupted data gracefully', async () => { + // Manually set invalid encrypted data + localStorage.setItem('auth_tokens', 'invalid_encrypted_data'); + + const { decryptData } = require('@/lib/auth/crypto'); + decryptData.mockRejectedValueOnce(new Error('Decryption failed')); + + const result = await getTokens(); + expect(result).toBeNull(); + + // Should clear corrupted data + expect(localStorage.getItem('auth_tokens')).toBeNull(); + }); + + it('should validate token structure after decryption', async () => { + const { decryptData } = require('@/lib/auth/crypto'); + + // Mock decryptData to return invalid structure + decryptData.mockResolvedValueOnce('not_an_object'); + + localStorage.setItem('auth_tokens', 'encrypted_data'); + + const result = await getTokens(); + expect(result).toBeNull(); + }); + + it('should reject tokens with missing fields', async () => { + const { decryptData } = require('@/lib/auth/crypto'); + + // Mock decryptData to return incomplete tokens + decryptData.mockResolvedValueOnce(JSON.stringify({ accessToken: 'only_access' })); + + localStorage.setItem('auth_tokens', 'encrypted_data'); + + const result = await getTokens(); + + // Should still return the object (validation is minimal) + expect(result).toEqual({ accessToken: 'only_access' }); + }); + }); + + describe('clearTokens', () => { + it('should clear all stored tokens', async () => { + const tokens = { + accessToken: 'test.access.token', + refreshToken: 'test.refresh.token', + }; + + await saveTokens(tokens); + expect(localStorage.getItem('auth_tokens')).not.toBeNull(); + + await clearTokens(); + expect(localStorage.getItem('auth_tokens')).toBeNull(); + }); + + it('should not throw if no tokens exist', async () => { + await expect(clearTokens()).resolves.not.toThrow(); + }); + + it('should call clearEncryptionKey', async () => { + const { clearEncryptionKey } = require('@/lib/auth/crypto'); + + await clearTokens(); + + expect(clearEncryptionKey).toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should throw clear error when localStorage not available', async () => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new Error('localStorage disabled'); + }); + + const tokens = { + accessToken: 'test.access.token', + refreshToken: 'test.refresh.token', + }; + + await expect(saveTokens(tokens)).rejects.toThrow('Token storage failed'); + + Storage.prototype.setItem = originalSetItem; + }); + }); +}); diff --git a/frontend/tests/stores/authStore.test.ts b/frontend/tests/stores/authStore.test.ts new file mode 100644 index 0000000..cc5bfd1 --- /dev/null +++ b/frontend/tests/stores/authStore.test.ts @@ -0,0 +1,340 @@ +/** + * Tests for auth store + */ + +import { useAuthStore } from '@/stores/authStore'; +import * as storage from '@/lib/auth/storage'; + +// Mock storage module +jest.mock('@/lib/auth/storage'); + +describe('Auth Store', () => { + beforeEach(() => { + // Reset store state + useAuthStore.setState({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + tokenExpiresAt: null, + }); + + jest.clearAllMocks(); + }); + + describe('User validation', () => { + it('should reject empty string user ID', async () => { + const invalidUser = { + id: '', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + 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 = { + id: ' ', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + 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 = { + id: 'user-123', + email: '', + is_active: true, + is_superuser: false, + }; + + 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 = { + id: 123, + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + await expect( + // @ts-expect-error - Testing invalid input + useAuthStore.getState().setAuth( + invalidUser, + 'valid.access.token', + 'valid.refresh.token' + ) + ).rejects.toThrow('Invalid user object'); + }); + + it('should accept valid user', async () => { + const validUser = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + 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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + 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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + (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 = { + id: 'user-123', + email: 'test@example.com', + is_active: true, + is_superuser: false, + }; + + 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); + }); + }); +});