1185 lines
36 KiB
JavaScript
1185 lines
36 KiB
JavaScript
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const jwt = require('jsonwebtoken');
|
|
const crypto = require('crypto');
|
|
const { OAuth2Client } = require('google-auth-library');
|
|
|
|
// Mock dependencies
|
|
jest.mock('jsonwebtoken');
|
|
jest.mock('google-auth-library');
|
|
jest.mock('sequelize', () => ({
|
|
Op: {
|
|
or: 'or'
|
|
}
|
|
}));
|
|
|
|
jest.mock('../../../models', () => ({
|
|
User: {
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
findByPk: jest.fn()
|
|
},
|
|
AlphaInvitation: {
|
|
findOne: jest.fn()
|
|
}
|
|
}));
|
|
|
|
// Mock middleware
|
|
jest.mock('../../../middleware/validation', () => ({
|
|
sanitizeInput: (req, res, next) => next(),
|
|
validateRegistration: (req, res, next) => next(),
|
|
validateLogin: (req, res, next) => next(),
|
|
validateGoogleAuth: (req, res, next) => next(),
|
|
validateForgotPassword: (req, res, next) => next(),
|
|
validateResetPassword: (req, res, next) => next(),
|
|
validateVerifyResetToken: (req, res, next) => next(),
|
|
}));
|
|
|
|
jest.mock('../../../middleware/csrf', () => ({
|
|
csrfProtection: (req, res, next) => next(),
|
|
getCSRFToken: (req, res) => res.json({ csrfToken: 'test-csrf-token' })
|
|
}));
|
|
|
|
jest.mock('../../../middleware/rateLimiter', () => ({
|
|
loginLimiter: (req, res, next) => next(),
|
|
registerLimiter: (req, res, next) => next(),
|
|
passwordResetLimiter: (req, res, next) => next(),
|
|
emailVerificationLimiter: (req, res, next) => next(),
|
|
}));
|
|
|
|
jest.mock('../../../middleware/auth', () => ({
|
|
optionalAuth: (req, res, next) => next(),
|
|
authenticateToken: (req, res, next) => {
|
|
req.user = { id: 'user-123' };
|
|
next();
|
|
},
|
|
}));
|
|
|
|
jest.mock('../../../services/email', () => ({
|
|
auth: {
|
|
sendVerificationEmail: jest.fn().mockResolvedValue(),
|
|
sendPasswordResetEmail: jest.fn().mockResolvedValue(),
|
|
sendPasswordChangedEmail: jest.fn().mockResolvedValue(),
|
|
}
|
|
}));
|
|
|
|
jest.mock('../../../utils/logger', () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
withRequestId: jest.fn(() => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
const { User, AlphaInvitation } = require('../../../models');
|
|
const emailService = require('../../../services/email');
|
|
|
|
// Set up OAuth2Client mock before requiring authRoutes
|
|
const mockGoogleClient = {
|
|
verifyIdToken: jest.fn(),
|
|
getToken: jest.fn()
|
|
};
|
|
OAuth2Client.mockImplementation(() => mockGoogleClient);
|
|
|
|
const authRoutes = require('../../../routes/auth');
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/auth', authRoutes);
|
|
|
|
// Add error handler
|
|
app.use((err, req, res, next) => {
|
|
res.status(500).json({ error: err.message });
|
|
});
|
|
|
|
describe('Auth Routes', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Reset environment
|
|
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
|
|
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
|
process.env.GOOGLE_CLIENT_ID = 'test-google-client-id';
|
|
process.env.NODE_ENV = 'test';
|
|
delete process.env.ALPHA_TESTING_ENABLED;
|
|
|
|
// Reset JWT mock to return different tokens for each call
|
|
let tokenCallCount = 0;
|
|
jwt.sign.mockImplementation(() => {
|
|
tokenCallCount++;
|
|
return tokenCallCount % 2 === 1 ? 'access-token' : 'refresh-token';
|
|
});
|
|
});
|
|
|
|
describe('GET /auth/csrf-token', () => {
|
|
it('should return CSRF token', async () => {
|
|
const response = await request(app)
|
|
.get('/auth/csrf-token');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toHaveProperty('csrfToken');
|
|
expect(response.body.csrfToken).toBe('test-csrf-token');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/register', () => {
|
|
it('should register a new user successfully', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const newUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: false,
|
|
jwtVersion: 1,
|
|
role: 'user',
|
|
verificationToken: 'test-verification-token',
|
|
generateVerificationToken: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.create.mockResolvedValue(newUser);
|
|
emailService.auth.sendVerificationEmail.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.post('/auth/register')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'StrongPass123!',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
phone: '1234567890'
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: false
|
|
});
|
|
expect(response.body.verificationEmailSent).toBe(true);
|
|
expect(newUser.generateVerificationToken).toHaveBeenCalled();
|
|
expect(emailService.auth.sendVerificationEmail).toHaveBeenCalledWith(newUser, newUser.verificationToken);
|
|
|
|
// Check that cookies are set
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('accessToken'),
|
|
expect.stringContaining('refreshToken')
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should reject registration with existing email', async () => {
|
|
User.findOne.mockResolvedValue({ id: 1, email: 'test@example.com' });
|
|
|
|
const response = await request(app)
|
|
.post('/auth/register')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'StrongPass123!',
|
|
firstName: 'Test',
|
|
lastName: 'User'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Registration failed');
|
|
expect(response.body.details[0].message).toBe('An account with this email already exists');
|
|
});
|
|
|
|
it('should handle registration errors', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
User.create.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/register')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'StrongPass123!',
|
|
firstName: 'Test',
|
|
lastName: 'User'
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toBe('Registration failed. Please try again.');
|
|
});
|
|
|
|
it('should continue registration even if verification email fails', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const newUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: false,
|
|
jwtVersion: 1,
|
|
role: 'user',
|
|
verificationToken: 'test-verification-token',
|
|
generateVerificationToken: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.create.mockResolvedValue(newUser);
|
|
emailService.auth.sendVerificationEmail.mockRejectedValue(new Error('Email service down'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/register')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'StrongPass123!',
|
|
firstName: 'Test',
|
|
lastName: 'User'
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.user.id).toBe(1);
|
|
expect(response.body.verificationEmailSent).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/login', () => {
|
|
it('should login user with valid credentials', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user',
|
|
isLocked: jest.fn().mockReturnValue(false),
|
|
comparePassword: jest.fn().mockResolvedValue(true),
|
|
resetLoginAttempts: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User'
|
|
});
|
|
expect(mockUser.resetLoginAttempts).toHaveBeenCalled();
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('accessToken'),
|
|
expect.stringContaining('refreshToken')
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should reject login with invalid email', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'nonexistent@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Please check your email and password, or create an account.');
|
|
});
|
|
|
|
it('should reject login with invalid password', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isLocked: jest.fn().mockReturnValue(false),
|
|
comparePassword: jest.fn().mockResolvedValue(false),
|
|
incLoginAttempts: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'wrongpassword'
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Please check your email and password, or create an account.');
|
|
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject login for locked account', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isLocked: jest.fn().mockReturnValue(true)
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(423);
|
|
expect(response.body.error).toContain('Account is temporarily locked');
|
|
});
|
|
|
|
it('should handle login errors', async () => {
|
|
User.findOne.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toBe('Login failed. Please try again.');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/google', () => {
|
|
const mockGooglePayload = {
|
|
sub: 'google123',
|
|
email: 'test@gmail.com',
|
|
given_name: 'Test',
|
|
family_name: 'User',
|
|
picture: 'https://example.com/profile.jpg'
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockGoogleClient.getToken.mockResolvedValue({
|
|
tokens: { id_token: 'mock-id-token' }
|
|
});
|
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
|
getPayload: () => mockGooglePayload
|
|
});
|
|
});
|
|
|
|
it('should handle Google OAuth login for new user', async () => {
|
|
User.findOne
|
|
.mockResolvedValueOnce(null) // No existing Google user
|
|
.mockResolvedValueOnce(null); // No existing email user
|
|
|
|
const newUser = {
|
|
id: 1,
|
|
email: 'test@gmail.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
imageFilename: 'https://example.com/profile.jpg',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user'
|
|
};
|
|
|
|
User.create.mockResolvedValue(newUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'valid-auth-code' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@gmail.com',
|
|
firstName: 'Test',
|
|
lastName: 'User'
|
|
});
|
|
expect(User.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
email: 'test@gmail.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
authProvider: 'google',
|
|
providerId: 'google123',
|
|
isVerified: true
|
|
}));
|
|
});
|
|
|
|
it('should handle Google OAuth login for existing user', async () => {
|
|
const existingUser = {
|
|
id: 1,
|
|
email: 'test@gmail.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user'
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(existingUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'valid-auth-code' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@gmail.com'
|
|
});
|
|
});
|
|
|
|
it('should reject when email exists with different auth provider', async () => {
|
|
User.findOne
|
|
.mockResolvedValueOnce(null) // No Google user
|
|
.mockResolvedValueOnce({ id: 1, email: 'test@gmail.com', authProvider: 'local' }); // Existing email user
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'valid-auth-code' });
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.error).toContain('An account with this email already exists');
|
|
});
|
|
|
|
it('should reject missing authorization code', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Authorization code is required');
|
|
});
|
|
|
|
it('should handle invalid authorization code', async () => {
|
|
mockGoogleClient.getToken.mockRejectedValue(new Error('invalid_grant'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'invalid-code' });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toContain('Invalid or expired authorization code');
|
|
});
|
|
|
|
it('should handle redirect URI mismatch', async () => {
|
|
mockGoogleClient.getToken.mockRejectedValue(new Error('redirect_uri_mismatch'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'some-code' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('Redirect URI mismatch');
|
|
});
|
|
|
|
it('should handle missing email permission', async () => {
|
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
|
getPayload: () => ({ sub: 'google123' }) // No email
|
|
});
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'valid-auth-code' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('Email permission is required');
|
|
});
|
|
|
|
it('should generate fallback name from email when not provided', async () => {
|
|
mockGoogleClient.verifyIdToken.mockResolvedValue({
|
|
getPayload: () => ({
|
|
sub: 'google123',
|
|
email: 'john.doe@gmail.com'
|
|
// No given_name or family_name
|
|
})
|
|
});
|
|
|
|
User.findOne
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null);
|
|
|
|
const newUser = {
|
|
id: 1,
|
|
email: 'john.doe@gmail.com',
|
|
firstName: 'John',
|
|
lastName: 'Doe',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user'
|
|
};
|
|
|
|
User.create.mockResolvedValue(newUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'valid-auth-code' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(User.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
firstName: 'John',
|
|
lastName: 'Doe'
|
|
}));
|
|
});
|
|
|
|
it('should handle Google auth errors gracefully', async () => {
|
|
mockGoogleClient.getToken.mockRejectedValue(new Error('Unknown error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/google')
|
|
.send({ code: 'some-code' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toBe('Google authentication failed. Please try again.');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/verify-email', () => {
|
|
it('should verify email with valid 6-digit code', async () => {
|
|
const mockUser = {
|
|
id: 'user-123',
|
|
email: 'test@example.com',
|
|
isVerified: false,
|
|
verificationToken: '123456',
|
|
verificationTokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now
|
|
verificationAttempts: 0,
|
|
isVerificationLocked: jest.fn().mockReturnValue(false),
|
|
isVerificationTokenValid: jest.fn().mockReturnValue(true),
|
|
verifyEmail: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toBe('Email verified successfully');
|
|
expect(response.body.user).toMatchObject({
|
|
id: 'user-123',
|
|
email: 'test@example.com',
|
|
isVerified: true
|
|
});
|
|
expect(mockUser.verifyEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject missing code', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Verification code required');
|
|
expect(response.body.code).toBe('CODE_REQUIRED');
|
|
});
|
|
|
|
it('should reject invalid code format (not 6 digits)', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '12345' }); // Only 5 digits
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Verification code must be 6 digits');
|
|
expect(response.body.code).toBe('INVALID_CODE_FORMAT');
|
|
});
|
|
|
|
it('should reject when user not found', async () => {
|
|
User.findByPk.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error).toBe('User not found');
|
|
expect(response.body.code).toBe('USER_NOT_FOUND');
|
|
});
|
|
|
|
it('should reject already verified user', async () => {
|
|
const mockUser = {
|
|
id: 'user-123',
|
|
isVerified: true
|
|
};
|
|
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Email already verified');
|
|
expect(response.body.code).toBe('ALREADY_VERIFIED');
|
|
});
|
|
|
|
it('should reject when too many verification attempts', async () => {
|
|
const mockUser = {
|
|
id: 'user-123',
|
|
isVerified: false,
|
|
isVerificationLocked: jest.fn().mockReturnValue(true)
|
|
};
|
|
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(response.body.error).toContain('Too many verification attempts');
|
|
expect(response.body.code).toBe('TOO_MANY_ATTEMPTS');
|
|
});
|
|
|
|
it('should reject when no verification code exists', async () => {
|
|
const mockUser = {
|
|
id: 'user-123',
|
|
isVerified: false,
|
|
verificationToken: null,
|
|
isVerificationLocked: jest.fn().mockReturnValue(false)
|
|
};
|
|
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('No verification code found');
|
|
expect(response.body.code).toBe('NO_CODE');
|
|
});
|
|
|
|
it('should reject expired verification code', async () => {
|
|
const mockUser = {
|
|
id: 'user-123',
|
|
isVerified: false,
|
|
verificationToken: '123456',
|
|
verificationTokenExpiry: new Date(Date.now() - 3600000), // 1 hour ago (expired)
|
|
isVerificationLocked: jest.fn().mockReturnValue(false)
|
|
};
|
|
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('expired');
|
|
expect(response.body.code).toBe('VERIFICATION_EXPIRED');
|
|
});
|
|
|
|
it('should handle verification errors', async () => {
|
|
User.findByPk.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-email')
|
|
.send({ code: '123456' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toBe('Email verification failed. Please try again.');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/resend-verification', () => {
|
|
it('should resend verification email for authenticated unverified user', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
isVerified: false,
|
|
verificationToken: 'new-token',
|
|
generateVerificationToken: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
jwt.verify.mockReturnValue({ id: 1 });
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
emailService.auth.sendVerificationEmail.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification')
|
|
.set('Cookie', ['accessToken=valid-token']);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toBe('Verification email sent successfully');
|
|
expect(mockUser.generateVerificationToken).toHaveBeenCalled();
|
|
expect(emailService.auth.sendVerificationEmail).toHaveBeenCalledWith(mockUser, mockUser.verificationToken);
|
|
});
|
|
|
|
it('should reject when no access token provided', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Authentication required');
|
|
expect(response.body.code).toBe('NO_TOKEN');
|
|
});
|
|
|
|
it('should reject expired access token', async () => {
|
|
const error = new Error('jwt expired');
|
|
error.name = 'TokenExpiredError';
|
|
jwt.verify.mockImplementation(() => { throw error; });
|
|
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification')
|
|
.set('Cookie', ['accessToken=expired-token']);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toContain('Session expired');
|
|
expect(response.body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
|
|
it('should reject when user not found', async () => {
|
|
jwt.verify.mockReturnValue({ id: 999 });
|
|
User.findByPk.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification')
|
|
.set('Cookie', ['accessToken=valid-token']);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error).toBe('User not found');
|
|
});
|
|
|
|
it('should reject when user already verified', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isVerified: true
|
|
};
|
|
|
|
jwt.verify.mockReturnValue({ id: 1 });
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification')
|
|
.set('Cookie', ['accessToken=valid-token']);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe('Email already verified');
|
|
});
|
|
|
|
it('should handle email service failure', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isVerified: false,
|
|
generateVerificationToken: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
jwt.verify.mockReturnValue({ id: 1 });
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
emailService.auth.sendVerificationEmail.mockRejectedValue(new Error('Email failed'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/resend-verification')
|
|
.set('Cookie', ['accessToken=valid-token']);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toContain('Failed to send verification email');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/refresh', () => {
|
|
it('should refresh access token with valid refresh token', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user'
|
|
};
|
|
|
|
jwt.verify.mockReturnValue({ id: 1, type: 'refresh', jwtVersion: 1 });
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', ['refreshToken=valid-refresh-token']);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@example.com'
|
|
});
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('accessToken')
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should reject missing refresh token', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/refresh');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Refresh token required');
|
|
});
|
|
|
|
it('should reject invalid refresh token', async () => {
|
|
jwt.verify.mockImplementation(() => { throw new Error('Invalid token'); });
|
|
|
|
const response = await request(app)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', ['refreshToken=invalid-token']);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Invalid or expired refresh token');
|
|
});
|
|
|
|
it('should reject non-refresh token type', async () => {
|
|
jwt.verify.mockReturnValue({ id: 1 }); // Missing type: 'refresh'
|
|
|
|
const response = await request(app)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', ['refreshToken=access-token']);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('Invalid refresh token');
|
|
});
|
|
|
|
it('should reject refresh token for non-existent user', async () => {
|
|
jwt.verify.mockReturnValue({ id: 999, type: 'refresh' });
|
|
User.findByPk.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', ['refreshToken=valid-token']);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toBe('User not found');
|
|
});
|
|
|
|
it('should reject token with mismatched jwtVersion', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
jwtVersion: 2 // Different from token's jwtVersion
|
|
};
|
|
|
|
jwt.verify.mockReturnValue({ id: 1, type: 'refresh', jwtVersion: 1 });
|
|
User.findByPk.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/refresh')
|
|
.set('Cookie', ['refreshToken=old-token']);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toContain('password change');
|
|
expect(response.body.code).toBe('JWT_VERSION_MISMATCH');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/logout', () => {
|
|
it('should logout user and clear cookies', async () => {
|
|
const response = await request(app)
|
|
.post('/auth/logout');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toBe('Logged out successfully');
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('accessToken=;'),
|
|
expect.stringContaining('refreshToken=;')
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET /auth/status', () => {
|
|
it('should return authenticated true when user is logged in', async () => {
|
|
// The optionalAuth middleware sets req.user if authenticated
|
|
// We need to modify the mock for this specific test
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true
|
|
};
|
|
|
|
// Create a custom app for this test with user set
|
|
const statusApp = express();
|
|
statusApp.use(express.json());
|
|
statusApp.use((req, res, next) => {
|
|
req.user = mockUser;
|
|
next();
|
|
});
|
|
statusApp.use('/auth', authRoutes);
|
|
|
|
const response = await request(statusApp)
|
|
.get('/auth/status');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.authenticated).toBe(true);
|
|
expect(response.body.user).toMatchObject({
|
|
id: 1,
|
|
email: 'test@example.com'
|
|
});
|
|
});
|
|
|
|
it('should return authenticated false when user is not logged in', async () => {
|
|
const response = await request(app)
|
|
.get('/auth/status');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.authenticated).toBe(false);
|
|
expect(response.body.user).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/forgot-password', () => {
|
|
it('should send password reset email for existing user', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
authProvider: 'local',
|
|
generatePasswordResetToken: jest.fn().mockResolvedValue('reset-token')
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
emailService.auth.sendPasswordResetEmail.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.post('/auth/forgot-password')
|
|
.send({ email: 'test@example.com' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toContain('If an account exists');
|
|
expect(mockUser.generatePasswordResetToken).toHaveBeenCalled();
|
|
expect(emailService.auth.sendPasswordResetEmail).toHaveBeenCalledWith(mockUser, 'reset-token');
|
|
});
|
|
|
|
it('should return success even for non-existent email (security)', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/forgot-password')
|
|
.send({ email: 'nonexistent@example.com' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toContain('If an account exists');
|
|
});
|
|
|
|
it('should return success for OAuth user (security)', async () => {
|
|
User.findOne.mockResolvedValue(null); // Query for local provider returns null
|
|
|
|
const response = await request(app)
|
|
.post('/auth/forgot-password')
|
|
.send({ email: 'google@example.com' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toContain('If an account exists');
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
User.findOne.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/forgot-password')
|
|
.send({ email: 'test@example.com' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toContain('Failed to process password reset');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/verify-reset-token', () => {
|
|
it('should verify valid reset token', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
passwordResetToken: crypto.createHash('sha256').update('valid-token').digest('hex'),
|
|
isPasswordResetTokenValid: jest.fn().mockReturnValue(true)
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-reset-token')
|
|
.send({ token: 'valid-token' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.valid).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid reset token', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-reset-token')
|
|
.send({ token: 'invalid-token' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.valid).toBe(false);
|
|
expect(response.body.code).toBe('TOKEN_INVALID');
|
|
});
|
|
|
|
it('should reject expired reset token', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isPasswordResetTokenValid: jest.fn().mockReturnValue(false)
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/verify-reset-token')
|
|
.send({ token: 'expired-token' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.valid).toBe(false);
|
|
expect(response.body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
});
|
|
|
|
describe('POST /auth/reset-password', () => {
|
|
it('should reset password with valid token', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
passwordResetToken: crypto.createHash('sha256').update('valid-token').digest('hex'),
|
|
isPasswordResetTokenValid: jest.fn().mockReturnValue(true),
|
|
resetPassword: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
emailService.auth.sendPasswordChangedEmail.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.post('/auth/reset-password')
|
|
.send({
|
|
token: 'valid-token',
|
|
newPassword: 'NewStrongPass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toContain('Password has been reset successfully');
|
|
expect(mockUser.resetPassword).toHaveBeenCalledWith('NewStrongPass123!');
|
|
expect(emailService.auth.sendPasswordChangedEmail).toHaveBeenCalledWith(mockUser);
|
|
});
|
|
|
|
it('should reject invalid reset token', async () => {
|
|
User.findOne.mockResolvedValue(null);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/reset-password')
|
|
.send({
|
|
token: 'invalid-token',
|
|
newPassword: 'NewStrongPass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('Invalid or expired reset token');
|
|
expect(response.body.code).toBe('TOKEN_INVALID');
|
|
});
|
|
|
|
it('should reject expired reset token', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
isPasswordResetTokenValid: jest.fn().mockReturnValue(false)
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/reset-password')
|
|
.send({
|
|
token: 'expired-token',
|
|
newPassword: 'NewStrongPass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('expired');
|
|
expect(response.body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
|
|
it('should handle reset password errors', async () => {
|
|
User.findOne.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app)
|
|
.post('/auth/reset-password')
|
|
.send({
|
|
token: 'some-token',
|
|
newPassword: 'NewStrongPass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error).toContain('Failed to reset password');
|
|
});
|
|
});
|
|
|
|
describe('Cookie settings', () => {
|
|
it('should set secure cookies in production', async () => {
|
|
process.env.NODE_ENV = 'prod';
|
|
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user',
|
|
isLocked: jest.fn().mockReturnValue(false),
|
|
comparePassword: jest.fn().mockResolvedValue(true),
|
|
resetLoginAttempts: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringMatching(/accessToken=.*Secure/i),
|
|
expect.stringMatching(/refreshToken=.*Secure/i)
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should set httpOnly cookies', async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
isVerified: true,
|
|
jwtVersion: 1,
|
|
role: 'user',
|
|
isLocked: jest.fn().mockReturnValue(false),
|
|
comparePassword: jest.fn().mockResolvedValue(true),
|
|
resetLoginAttempts: jest.fn().mockResolvedValue()
|
|
};
|
|
|
|
User.findOne.mockResolvedValue(mockUser);
|
|
|
|
const response = await request(app)
|
|
.post('/auth/login')
|
|
.send({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers['set-cookie']).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('HttpOnly'),
|
|
expect.stringContaining('HttpOnly')
|
|
])
|
|
);
|
|
});
|
|
});
|
|
});
|