feat: add JWT authentication with complete test coverage

Phase 1 - Step 3: Authentication API

**Backend Authentication:**
- bcryptjs for password hashing (salt rounds: 10)
- JWT tokens with 24h expiration
- Secure password storage (never expose passwordHash)

**API Endpoints:**
- POST /api/auth/register - User registration
  - Username validation (3-50 chars, alphanumeric + underscore)
  - Email validation and normalization
  - Password validation (min 6 chars)
  - Duplicate email/username detection
  - Auto-generated avatar (ui-avatars.com)

- POST /api/auth/login - User authentication
  - Email + password credentials
  - Returns JWT token + user data
  - Invalid credentials protection

- GET /api/users/me - Get current user (protected)
  - Requires valid JWT token
  - Returns user data + stats (matches, ratings)
  - Token validation via middleware

**Security Features:**
- express-validator for input sanitization
- Auth middleware for protected routes
- Token verification (Bearer token)
- Password never returned in responses
- Proper error messages (no information leakage)

**Frontend Integration:**
- API service layer (frontend/src/services/api.js)
- Updated AuthContext to use real API
- Token storage in localStorage
- Automatic token inclusion in requests
- Error handling for expired/invalid tokens

**Unit Tests (30 tests, 78.26% coverage):**

Auth Endpoints (14 tests):
-  Register: success, duplicate email, duplicate username
-  Register validation: invalid email, short password, short username
-  Login: success, wrong password, non-existent user, invalid format
-  Protected route: valid token, no token, invalid token, malformed header

Auth Utils (9 tests):
-  Password hashing and comparison
-  Different hashes for same password
-  JWT generation and verification
-  Token expiration validation
-  Invalid token handling

All tests passing 
Coverage: 78.26% 
This commit is contained in:
Radosław Gierwiało
2025-11-12 22:16:14 +01:00
parent 0e62b12f5e
commit 3788274f73
14 changed files with 997 additions and 43 deletions

View File

@@ -0,0 +1,244 @@
const request = require('supertest');
const app = require('../app');
const { prisma } = require('../utils/db');
const { hashPassword, generateToken } = require('../utils/auth');
// Clean up database before and after tests
beforeAll(async () => {
await prisma.user.deleteMany({});
});
afterAll(async () => {
await prisma.user.deleteMany({});
await prisma.$disconnect();
});
describe('Authentication API Tests', () => {
describe('POST /api/auth/register', () => {
it('should register a new user successfully', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('message', 'User registered successfully');
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
expect(response.body.data.user).toHaveProperty('id');
expect(response.body.data.user).toHaveProperty('username', 'testuser');
expect(response.body.data.user).toHaveProperty('email', 'test@example.com');
expect(response.body.data.user).toHaveProperty('avatar');
expect(response.body.data.user).not.toHaveProperty('passwordHash');
});
it('should reject registration with duplicate email', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'anotheruser',
email: 'test@example.com', // Same email as above
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Email already registered');
});
it('should reject registration with duplicate username', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'testuser', // Same username as above
email: 'another@example.com',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Username already taken');
});
it('should reject registration with invalid email', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'newuser',
email: 'invalid-email',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Validation Error');
});
it('should reject registration with short password', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'newuser',
email: 'new@example.com',
password: '12345', // Too short
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Validation Error');
});
it('should reject registration with invalid username', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
username: 'ab', // Too short
email: 'new@example.com',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Validation Error');
});
});
describe('POST /api/auth/login', () => {
it('should login successfully with correct credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('message', 'Login successful');
expect(response.body.data).toHaveProperty('user');
expect(response.body.data).toHaveProperty('token');
expect(response.body.data.user).toHaveProperty('email', 'test@example.com');
expect(response.body.data.user).not.toHaveProperty('passwordHash');
});
it('should reject login with incorrect password', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'wrongpassword',
})
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
});
it('should reject login with non-existent email', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
});
it('should reject login with invalid email format', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'invalid-email',
password: 'password123',
})
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Validation Error');
});
});
describe('GET /api/users/me', () => {
let authToken;
beforeAll(async () => {
// Create a user and get token
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'password123',
});
authToken = response.body.data.token;
});
it('should return current user with valid token', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('id');
expect(response.body.data).toHaveProperty('username', 'testuser');
expect(response.body.data).toHaveProperty('email', 'test@example.com');
expect(response.body.data).toHaveProperty('stats');
expect(response.body.data.stats).toHaveProperty('matchesCount');
expect(response.body.data.stats).toHaveProperty('ratingsCount');
expect(response.body.data.stats).toHaveProperty('rating');
expect(response.body.data).not.toHaveProperty('passwordHash');
});
it('should reject request without token', async () => {
const response = await request(app)
.get('/api/users/me')
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Unauthorized');
expect(response.body).toHaveProperty('message', 'No token provided');
});
it('should reject request with invalid token', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', 'Bearer invalid-token')
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Unauthorized');
expect(response.body).toHaveProperty('message', 'Invalid or expired token');
});
it('should reject request with malformed Authorization header', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', authToken) // Missing 'Bearer ' prefix
.expect('Content-Type', /json/)
.expect(401);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Unauthorized');
});
});
});

View File

@@ -0,0 +1,99 @@
const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth');
// Set up test environment variables
beforeAll(() => {
process.env.JWT_SECRET = 'test-secret-key';
process.env.JWT_EXPIRES_IN = '24h';
});
describe('Auth Utils Tests', () => {
describe('hashPassword and comparePassword', () => {
it('should hash password successfully', async () => {
const password = 'mySecretPassword123';
const hash = await hashPassword(password);
expect(hash).toBeDefined();
expect(hash).not.toBe(password);
expect(hash.length).toBeGreaterThan(0);
});
it('should compare password with hash successfully', async () => {
const password = 'mySecretPassword123';
const hash = await hashPassword(password);
const isValid = await comparePassword(password, hash);
expect(isValid).toBe(true);
});
it('should reject incorrect password', async () => {
const password = 'mySecretPassword123';
const wrongPassword = 'wrongPassword';
const hash = await hashPassword(password);
const isValid = await comparePassword(wrongPassword, hash);
expect(isValid).toBe(false);
});
it('should generate different hashes for same password', async () => {
const password = 'mySecretPassword123';
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
expect(hash1).not.toBe(hash2);
// But both should be valid
expect(await comparePassword(password, hash1)).toBe(true);
expect(await comparePassword(password, hash2)).toBe(true);
});
});
describe('generateToken and verifyToken', () => {
it('should generate a valid JWT token', () => {
const payload = { userId: 123 };
const token = generateToken(payload);
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
});
it('should verify a valid token', () => {
const payload = { userId: 123, email: 'test@example.com' };
const token = generateToken(payload);
const decoded = verifyToken(token);
expect(decoded).toBeDefined();
expect(decoded).toHaveProperty('userId', 123);
expect(decoded).toHaveProperty('email', 'test@example.com');
expect(decoded).toHaveProperty('iat'); // Issued at
expect(decoded).toHaveProperty('exp'); // Expiration
});
it('should reject invalid token', () => {
const invalidToken = 'invalid.token.here';
const decoded = verifyToken(invalidToken);
expect(decoded).toBeNull();
});
it('should reject malformed token', () => {
const malformedToken = 'notavalidtoken';
const decoded = verifyToken(malformedToken);
expect(decoded).toBeNull();
});
it('should include expiration time in token', () => {
const payload = { userId: 123 };
const token = generateToken(payload);
const decoded = verifyToken(token);
expect(decoded).toHaveProperty('exp');
expect(decoded.exp).toBeGreaterThan(decoded.iat);
// Should expire in approximately 24 hours (allowing 1 minute tolerance)
const expectedExpiration = decoded.iat + (24 * 60 * 60);
expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60);
});
});
});