Backend Infrastructure: - Complete Express server setup with security middleware (helmet, CORS, rate limiting) - Comprehensive error handling and logging with Winston - Authentication system with JWT tokens and session management - Database models and migrations for Users, Documents, Feedback, and Processing Jobs - API routes structure for authentication and document management - Integration tests for all server components (86 tests passing) Frontend Infrastructure: - React application with TypeScript and Vite - Authentication UI with login form, protected routes, and logout functionality - Authentication context with proper async state management - Component tests with proper async handling (25 tests passing) - Tailwind CSS styling and responsive design Key Features: - User registration, login, and authentication - Protected routes with role-based access control - Comprehensive error handling and user feedback - Database schema with proper relationships - Security middleware and validation - Production-ready build configuration Test Coverage: 111/111 tests passing Tasks Completed: 1-5 (Project setup, Database, Auth system, Frontend UI, Backend infrastructure) Ready for Task 6: File upload backend infrastructure
305 lines
9.3 KiB
TypeScript
305 lines
9.3 KiB
TypeScript
import {
|
|
generateAccessToken,
|
|
generateRefreshToken,
|
|
generateAuthTokens,
|
|
verifyAccessToken,
|
|
verifyRefreshToken,
|
|
hashPassword,
|
|
comparePassword,
|
|
validatePassword,
|
|
extractTokenFromHeader,
|
|
decodeToken
|
|
} from '../auth';
|
|
// Config is mocked below, so we don't need to import it
|
|
|
|
// Mock the config
|
|
jest.mock('../../config/env', () => ({
|
|
config: {
|
|
jwt: {
|
|
secret: 'test-secret',
|
|
refreshSecret: 'test-refresh-secret',
|
|
expiresIn: '1h',
|
|
refreshExpiresIn: '7d'
|
|
},
|
|
security: {
|
|
bcryptRounds: 10
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Mock logger
|
|
jest.mock('../logger', () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn()
|
|
}));
|
|
|
|
describe('Auth Utilities', () => {
|
|
const mockPayload = {
|
|
userId: '123e4567-e89b-12d3-a456-426614174000',
|
|
email: 'test@example.com',
|
|
role: 'user'
|
|
};
|
|
|
|
describe('generateAccessToken', () => {
|
|
it('should generate a valid access token', () => {
|
|
const token = generateAccessToken(mockPayload);
|
|
|
|
expect(token).toBeDefined();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
|
});
|
|
|
|
it('should include the correct payload in the token', () => {
|
|
const token = generateAccessToken(mockPayload);
|
|
const decoded = decodeToken(token);
|
|
|
|
expect(decoded).toMatchObject({
|
|
userId: mockPayload.userId,
|
|
email: mockPayload.email,
|
|
role: mockPayload.role,
|
|
iss: 'cim-processor',
|
|
aud: 'cim-processor-users'
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateRefreshToken', () => {
|
|
it('should generate a valid refresh token', () => {
|
|
const token = generateRefreshToken(mockPayload);
|
|
|
|
expect(token).toBeDefined();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.split('.')).toHaveLength(3);
|
|
});
|
|
|
|
it('should use refresh secret for signing', () => {
|
|
const token = generateRefreshToken(mockPayload);
|
|
const decoded = decodeToken(token);
|
|
|
|
expect(decoded).toMatchObject({
|
|
userId: mockPayload.userId,
|
|
email: mockPayload.email,
|
|
role: mockPayload.role
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateAuthTokens', () => {
|
|
it('should generate both access and refresh tokens', () => {
|
|
const tokens = generateAuthTokens(mockPayload);
|
|
|
|
expect(tokens).toHaveProperty('accessToken');
|
|
expect(tokens).toHaveProperty('refreshToken');
|
|
expect(tokens).toHaveProperty('expiresIn');
|
|
expect(typeof tokens.accessToken).toBe('string');
|
|
expect(typeof tokens.refreshToken).toBe('string');
|
|
expect(typeof tokens.expiresIn).toBe('number');
|
|
});
|
|
|
|
it('should calculate correct expiration time', () => {
|
|
const tokens = generateAuthTokens(mockPayload);
|
|
|
|
// 1h = 3600 seconds
|
|
expect(tokens.expiresIn).toBe(3600);
|
|
});
|
|
});
|
|
|
|
describe('verifyAccessToken', () => {
|
|
it('should verify a valid access token', () => {
|
|
const token = generateAccessToken(mockPayload);
|
|
const decoded = verifyAccessToken(token);
|
|
|
|
expect(decoded).toMatchObject({
|
|
userId: mockPayload.userId,
|
|
email: mockPayload.email,
|
|
role: mockPayload.role
|
|
});
|
|
});
|
|
|
|
it('should throw error for invalid token', () => {
|
|
expect(() => {
|
|
verifyAccessToken('invalid-token');
|
|
}).toThrow('Invalid or expired access token');
|
|
});
|
|
|
|
it('should throw error for token signed with wrong secret', () => {
|
|
const token = generateRefreshToken(mockPayload); // Uses refresh secret
|
|
|
|
expect(() => {
|
|
verifyAccessToken(token); // Expects access secret
|
|
}).toThrow('Invalid or expired access token');
|
|
});
|
|
});
|
|
|
|
describe('verifyRefreshToken', () => {
|
|
it('should verify a valid refresh token', () => {
|
|
const token = generateRefreshToken(mockPayload);
|
|
const decoded = verifyRefreshToken(token);
|
|
|
|
expect(decoded).toMatchObject({
|
|
userId: mockPayload.userId,
|
|
email: mockPayload.email,
|
|
role: mockPayload.role
|
|
});
|
|
});
|
|
|
|
it('should throw error for invalid refresh token', () => {
|
|
expect(() => {
|
|
verifyRefreshToken('invalid-token');
|
|
}).toThrow('Invalid or expired refresh token');
|
|
});
|
|
});
|
|
|
|
describe('hashPassword', () => {
|
|
it('should hash password correctly', async () => {
|
|
const password = 'TestPassword123!';
|
|
const hashedPassword = await hashPassword(password);
|
|
|
|
expect(hashedPassword).toBeDefined();
|
|
expect(typeof hashedPassword).toBe('string');
|
|
expect(hashedPassword).not.toBe(password);
|
|
expect(hashedPassword.startsWith('$2a$') || hashedPassword.startsWith('$2b$')).toBe(true); // bcrypt format
|
|
});
|
|
|
|
it('should generate different hashes for same password', async () => {
|
|
const password = 'TestPassword123!';
|
|
const hash1 = await hashPassword(password);
|
|
const hash2 = await hashPassword(password);
|
|
|
|
expect(hash1).not.toBe(hash2);
|
|
});
|
|
});
|
|
|
|
describe('comparePassword', () => {
|
|
it('should return true for correct password', async () => {
|
|
const password = 'TestPassword123!';
|
|
const hashedPassword = await hashPassword(password);
|
|
const isMatch = await comparePassword(password, hashedPassword);
|
|
|
|
expect(isMatch).toBe(true);
|
|
});
|
|
|
|
it('should return false for incorrect password', async () => {
|
|
const password = 'TestPassword123!';
|
|
const wrongPassword = 'WrongPassword123!';
|
|
const hashedPassword = await hashPassword(password);
|
|
const isMatch = await comparePassword(wrongPassword, hashedPassword);
|
|
|
|
expect(isMatch).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validatePassword', () => {
|
|
it('should validate a strong password', () => {
|
|
const password = 'StrongPass123!';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should reject password that is too short', () => {
|
|
const password = 'Short1!';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Password must be at least 8 characters long');
|
|
});
|
|
|
|
it('should reject password without uppercase letter', () => {
|
|
const password = 'lowercase123!';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Password must contain at least one uppercase letter');
|
|
});
|
|
|
|
it('should reject password without lowercase letter', () => {
|
|
const password = 'UPPERCASE123!';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Password must contain at least one lowercase letter');
|
|
});
|
|
|
|
it('should reject password without number', () => {
|
|
const password = 'NoNumbers!';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Password must contain at least one number');
|
|
});
|
|
|
|
it('should reject password without special character', () => {
|
|
const password = 'NoSpecialChar123';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toContain('Password must contain at least one special character');
|
|
});
|
|
|
|
it('should return all validation errors for weak password', () => {
|
|
const password = 'weak';
|
|
const result = validatePassword(password);
|
|
|
|
expect(result.isValid).toBe(false);
|
|
expect(result.errors).toHaveLength(4); // 'weak' has lowercase, so only 4 errors
|
|
expect(result.errors).toContain('Password must be at least 8 characters long');
|
|
expect(result.errors).toContain('Password must contain at least one uppercase letter');
|
|
expect(result.errors).toContain('Password must contain at least one number');
|
|
expect(result.errors).toContain('Password must contain at least one special character');
|
|
});
|
|
});
|
|
|
|
describe('extractTokenFromHeader', () => {
|
|
it('should extract token from valid Authorization header', () => {
|
|
const header = 'Bearer valid-token-here';
|
|
const token = extractTokenFromHeader(header);
|
|
|
|
expect(token).toBe('valid-token-here');
|
|
});
|
|
|
|
it('should return null for missing header', () => {
|
|
const token = extractTokenFromHeader(undefined);
|
|
|
|
expect(token).toBeNull();
|
|
});
|
|
|
|
it('should return null for empty header', () => {
|
|
const token = extractTokenFromHeader('');
|
|
|
|
expect(token).toBeNull();
|
|
});
|
|
|
|
it('should return null for invalid format', () => {
|
|
const token = extractTokenFromHeader('InvalidFormat token');
|
|
|
|
expect(token).toBeNull();
|
|
});
|
|
|
|
it('should return null for missing token part', () => {
|
|
const token = extractTokenFromHeader('Bearer ');
|
|
|
|
expect(token).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('decodeToken', () => {
|
|
it('should decode a valid token', () => {
|
|
const token = generateAccessToken(mockPayload);
|
|
const decoded = decodeToken(token);
|
|
|
|
expect(decoded).toMatchObject({
|
|
userId: mockPayload.userId,
|
|
email: mockPayload.email,
|
|
role: mockPayload.role
|
|
});
|
|
});
|
|
|
|
it('should return null for invalid token', () => {
|
|
const decoded = decodeToken('invalid-token');
|
|
|
|
expect(decoded).toBeNull();
|
|
});
|
|
});
|
|
});
|