Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 58s
912 lines
34 KiB
TypeScript
912 lines
34 KiB
TypeScript
// src/routes/auth.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import cookieParser from 'cookie-parser'; // This was a duplicate, fixed.
|
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|
|
|
// --- FIX: Hoist passport mocks to be available for vi.mock ---
|
|
const passportMocks = vi.hoisted(() => {
|
|
type PassportCallback = (
|
|
error: Error | null,
|
|
user?: Express.User | false,
|
|
info?: { message: string },
|
|
) => void;
|
|
|
|
const authenticateMock =
|
|
(strategy: string, options: Record<string, unknown>, callback: PassportCallback) =>
|
|
(req: Request, res: Response, next: NextFunction) => {
|
|
// Simulate LocalStrategy logic based on request body
|
|
if (req.body.password === 'wrong_password') {
|
|
return callback(null, false, { message: 'Incorrect email or password.' });
|
|
}
|
|
if (req.body.email === 'locked@test.com') {
|
|
return callback(null, false, {
|
|
message: 'Account is temporarily locked. Please try again in 15 minutes.',
|
|
});
|
|
}
|
|
if (req.body.email === 'notfound@test.com') {
|
|
return callback(null, false, { message: 'Login failed' });
|
|
}
|
|
// Specific case for strategy error
|
|
if (req.body.email === 'dberror@test.com') {
|
|
return callback(new Error('Database connection failed'), false);
|
|
}
|
|
|
|
// Default success case
|
|
const user = createMockUserProfile({ user: { user_id: 'user-123', email: req.body.email } });
|
|
|
|
// If a callback is provided (custom callback signature), call it
|
|
if (callback) {
|
|
return callback(null, user);
|
|
}
|
|
|
|
// Standard middleware signature: attach user and call next
|
|
req.user = user;
|
|
next();
|
|
};
|
|
|
|
return { authenticateMock };
|
|
});
|
|
|
|
// --- 2. Module Mocks ---
|
|
|
|
// Mock the local passport.routes module to control its behavior.
|
|
vi.mock('./passport.routes', () => ({
|
|
default: {
|
|
authenticate: vi.fn().mockImplementation(passportMocks.authenticateMock),
|
|
use: vi.fn(),
|
|
initialize: () => (req: Request, res: Response, next: NextFunction) => next(),
|
|
session: () => (req: Request, res: Response, next: NextFunction) => next(),
|
|
},
|
|
// Also mock named exports if they were used in auth.routes.ts, though they are not currently.
|
|
isAdmin: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
|
|
optionalAuth: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
|
|
}));
|
|
|
|
// Mock the authService, which is now the primary dependency of the routes.
|
|
const { mockedAuthService } = vi.hoisted(() => {
|
|
return {
|
|
mockedAuthService: {
|
|
registerAndLoginUser: vi.fn(),
|
|
handleSuccessfulLogin: vi.fn(),
|
|
resetPassword: vi.fn(),
|
|
updatePassword: vi.fn(),
|
|
refreshAccessToken: vi.fn(),
|
|
logout: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../services/authService', () => ({ authService: mockedAuthService }));
|
|
|
|
// Mock the logger
|
|
vi.mock('../services/logger.server', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
// Mock the email service
|
|
vi.mock('../services/emailService.server', () => ({
|
|
sendPasswordResetEmail: vi.fn(),
|
|
}));
|
|
|
|
// Import the router AFTER mocks are established
|
|
import authRouter from './auth.routes';
|
|
|
|
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
|
|
// --- 4. App Setup using createTestApp ---
|
|
const app = createTestApp({
|
|
router: authRouter,
|
|
basePath: '/api/auth',
|
|
// Inject cookieParser via the new middleware option
|
|
middleware: [cookieParser()],
|
|
});
|
|
|
|
const { mockLogger } = await import('../tests/utils/mockLogger');
|
|
|
|
// --- 5. Tests ---
|
|
describe('Auth Routes (/api/auth)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks(); // Restore spies on prototypes
|
|
});
|
|
|
|
describe('POST /register', () => {
|
|
const newUserEmail = 'newuser@test.com';
|
|
const strongPassword = 'a-Very-Strong-Password-123!';
|
|
|
|
it('should successfully register a new user with a strong password', async () => {
|
|
// Arrange:
|
|
const mockNewUser = createMockUserProfile({
|
|
user: { user_id: 'new-user-id', email: newUserEmail },
|
|
full_name: 'Test User',
|
|
});
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: mockNewUser,
|
|
accessToken: 'new-access-token',
|
|
refreshToken: 'new-refresh-token',
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/register').send({
|
|
email: newUserEmail,
|
|
password: strongPassword,
|
|
full_name: 'Test User',
|
|
});
|
|
// Assert
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.data.message).toBe('User registered successfully!');
|
|
expect(response.body.data.userprofile.user.email).toBe(newUserEmail);
|
|
expect(response.body.data.token).toBeTypeOf('string'); // This was a duplicate, fixed.
|
|
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
|
newUserEmail,
|
|
strongPassword,
|
|
'Test User',
|
|
undefined, // avatar_url
|
|
mockLogger,
|
|
);
|
|
});
|
|
|
|
it('should allow registration with an empty string for avatar_url', async () => {
|
|
// Arrange
|
|
const email = 'avatar-user@test.com';
|
|
const mockNewUser = createMockUserProfile({
|
|
user: { user_id: 'avatar-user-id', email },
|
|
});
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: mockNewUser,
|
|
accessToken: 'avatar-access-token',
|
|
refreshToken: 'avatar-refresh-token',
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/register').send({
|
|
email,
|
|
password: strongPassword,
|
|
full_name: 'Avatar User',
|
|
avatar_url: '', // Send an empty string
|
|
});
|
|
|
|
// Assert
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.data.message).toBe('User registered successfully!');
|
|
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
|
email,
|
|
strongPassword,
|
|
'Avatar User',
|
|
undefined, // The preprocess step in the Zod schema should convert '' to undefined
|
|
mockLogger,
|
|
);
|
|
});
|
|
|
|
it('should allow registration with an empty string for full_name', async () => {
|
|
// Arrange
|
|
const email = 'empty-name@test.com';
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: createMockUserProfile({ user: { email } }),
|
|
accessToken: 'token',
|
|
refreshToken: 'token',
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/register').send({
|
|
email,
|
|
password: strongPassword,
|
|
full_name: '', // Send an empty string
|
|
});
|
|
|
|
// Assert
|
|
expect(response.status).toBe(201);
|
|
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
|
email,
|
|
strongPassword,
|
|
undefined, // The preprocess step in the Zod schema should convert '' to undefined
|
|
undefined,
|
|
mockLogger,
|
|
);
|
|
});
|
|
|
|
it('should set a refresh token cookie on successful registration', async () => {
|
|
const mockNewUser = createMockUserProfile({
|
|
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
|
|
});
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: mockNewUser,
|
|
accessToken: 'new-access-token',
|
|
refreshToken: 'new-refresh-token',
|
|
});
|
|
|
|
const response = await supertest(app).post('/api/auth/register').send({
|
|
email: 'cookie@test.com',
|
|
password: 'StrongPassword123!',
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.headers['set-cookie']).toBeDefined();
|
|
expect(response.headers['set-cookie'][0]).toContain('refreshToken=');
|
|
});
|
|
|
|
it('should reject registration with a weak password', async () => {
|
|
const weakPassword = 'password';
|
|
|
|
const response = await supertest(app).post('/api/auth/register').send({
|
|
email: 'anotheruser@test.com',
|
|
password: weakPassword,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
// The validation middleware returns errors in an array.
|
|
// We check if any of the error messages contain the expected text.
|
|
interface ZodError {
|
|
message: string;
|
|
}
|
|
const errorMessages = response.body.error.details?.map((e: ZodError) => e.message).join(' ');
|
|
expect(errorMessages).toMatch(/Password is too weak/i);
|
|
});
|
|
|
|
it('should reject registration if the auth service throws UniqueConstraintError', async () => {
|
|
// Create an error object that includes the 'code' property for simulating a PG unique violation.
|
|
// This is more type-safe than casting to 'any'.
|
|
const dbError = new UniqueConstraintError(
|
|
'User with that email already exists.',
|
|
) as UniqueConstraintError & { code: string };
|
|
dbError.code = '23505';
|
|
mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.send({ email: newUserEmail, password: strongPassword });
|
|
|
|
expect(response.status).toBe(409); // 409 Conflict
|
|
expect(response.body.error.message).toBe('User with that email already exists.');
|
|
});
|
|
|
|
it('should return 500 if a generic database error occurs during registration', async () => {
|
|
const dbError = new Error('DB connection lost');
|
|
mockedAuthService.registerAndLoginUser.mockRejectedValue(dbError);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.send({ email: 'fail@test.com', password: strongPassword });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('DB connection lost'); // The errorHandler will forward the message
|
|
});
|
|
|
|
it('should return 400 for an invalid email format', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.send({ email: 'not-an-email', password: strongPassword });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
|
});
|
|
|
|
it('should return 400 for a password that is too short', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.send({ email: newUserEmail, password: 'short' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe(
|
|
'Password must be at least 8 characters long.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('POST /login', () => {
|
|
it('should successfully log in a user and return a token and cookie', async () => {
|
|
// Arrange:
|
|
const loginCredentials = { email: 'test@test.com', password: 'password123' };
|
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
|
accessToken: 'new-access-token',
|
|
refreshToken: 'new-refresh-token',
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
// The API now returns a nested UserProfile object
|
|
expect(response.body.data.userprofile).toEqual(
|
|
expect.objectContaining({
|
|
user: expect.objectContaining({
|
|
user_id: 'user-123',
|
|
email: loginCredentials.email,
|
|
}),
|
|
}),
|
|
);
|
|
expect(response.body.data.token).toBeTypeOf('string');
|
|
expect(response.headers['set-cookie']).toBeDefined();
|
|
});
|
|
|
|
it('should reject login for incorrect credentials', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'test@test.com', password: 'wrong_password' });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error.message).toBe('Incorrect email or password.');
|
|
});
|
|
|
|
it('should reject login for a locked account', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'locked@test.com', password: 'password123' });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error.message).toBe(
|
|
'Account is temporarily locked. Please try again in 15 minutes.',
|
|
);
|
|
});
|
|
|
|
it('should return 401 if user is not found', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login') // This was a duplicate, fixed.
|
|
.send({ email: 'notfound@test.com', password: 'password123' });
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('should return 500 if saving the refresh token fails', async () => {
|
|
// Arrange:
|
|
const loginCredentials = { email: 'test@test.com', password: 'password123' };
|
|
mockedAuthService.handleSuccessfulLogin.mockRejectedValue(new Error('DB write failed'));
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
it('should return 500 if passport strategy returns an error', async () => {
|
|
// This test covers the `if (err)` block in the passport.authenticate callback.
|
|
// The mock implementation for passport.authenticate is configured to return an error
|
|
// when the email is 'dberror@test.com'.
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'dberror@test.com', password: 'any_password' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Database connection failed');
|
|
});
|
|
|
|
it('should log a warning when passport authentication fails without a user', async () => {
|
|
// This test specifically covers the `if (!user)` debug log line in the route.
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'notfound@test.com', password: 'any_password' });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
{ info: { message: 'Login failed' } },
|
|
'[API /login] Passport reported NO USER found.',
|
|
);
|
|
});
|
|
|
|
it('should set a long-lived cookie when rememberMe is true', async () => {
|
|
// Arrange
|
|
const loginCredentials = {
|
|
email: 'test@test.com',
|
|
password: 'password123',
|
|
rememberMe: true,
|
|
};
|
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
|
accessToken: 'remember-access-token',
|
|
refreshToken: 'remember-refresh-token',
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
expect(setCookieHeader[0]).toContain('Max-Age=2592000'); // 30 days in seconds
|
|
});
|
|
|
|
it('should return 400 for an invalid email format', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'not-an-email', password: 'password123' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
|
});
|
|
|
|
it('should return 400 if password is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'test@test.com' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe('Password is required.');
|
|
});
|
|
});
|
|
|
|
describe('POST /forgot-password', () => {
|
|
it('should send a reset link if the user exists', async () => {
|
|
// Arrange
|
|
mockedAuthService.resetPassword.mockResolvedValue('mock-reset-token');
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.send({ email: 'test@test.com' });
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
|
|
expect(response.body.data.token).toBeTypeOf('string');
|
|
});
|
|
|
|
it('should return a generic success message even if the user does not exist', async () => {
|
|
mockedAuthService.resetPassword.mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.send({ email: 'nouser@test.com' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.message).toContain('a password reset link has been sent');
|
|
});
|
|
|
|
it('should return 500 if the database call fails', async () => {
|
|
mockedAuthService.resetPassword.mockRejectedValue(new Error('DB connection failed'));
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.send({ email: 'any@test.com' });
|
|
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
it('should return 400 for an invalid email format', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.send({ email: 'invalid-email' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe('A valid email is required.');
|
|
});
|
|
});
|
|
|
|
describe('POST /reset-password', () => {
|
|
it('should reset the password with a valid token and strong password', async () => {
|
|
mockedAuthService.updatePassword.mockResolvedValue(true);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.message).toBe('Password has been reset successfully.');
|
|
});
|
|
|
|
it('should reject with an invalid or expired token', async () => {
|
|
mockedAuthService.updatePassword.mockResolvedValue(null);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token: 'invalid-token', newPassword: 'a-Very-Strong-Password-123!' }); // Use strong password to pass validation
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('Invalid or expired password reset token.');
|
|
});
|
|
|
|
it('should return 400 for a weak new password', async () => {
|
|
// No need to mock the service here as validation runs first
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token: 'valid-token', newPassword: 'weak' });
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 400 if token is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ newPassword: 'a-Very-Strong-Password-789!' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toMatch(/Token is required|Required/i);
|
|
});
|
|
|
|
it('should return 500 if updatePassword throws an error', async () => {
|
|
const dbError = new Error('Database connection failed');
|
|
mockedAuthService.updatePassword.mockRejectedValue(dbError);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: dbError },
|
|
'An error occurred during password reset.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('POST /refresh-token', () => {
|
|
it('should issue a new access token with a valid refresh token cookie', async () => {
|
|
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-access-token' });
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=valid-refresh-token');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.token).toBeTypeOf('string');
|
|
});
|
|
|
|
it('should return 401 if no refresh token cookie is provided', async () => {
|
|
const response = await supertest(app).post('/api/auth/refresh-token');
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error.message).toBe('Refresh token not found.');
|
|
});
|
|
|
|
it('should return 403 if refresh token is invalid', async () => {
|
|
mockedAuthService.refreshAccessToken.mockResolvedValue(null);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=invalid-token');
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it('should return 500 if the database call fails', async () => {
|
|
// Arrange
|
|
mockedAuthService.refreshAccessToken.mockRejectedValue(new Error('DB Error'));
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=any-token');
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toMatch(/DB Error/);
|
|
});
|
|
});
|
|
|
|
describe('POST /logout', () => {
|
|
it('should clear the refresh token cookie and return a success message', async () => {
|
|
// Arrange
|
|
mockedAuthService.logout.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/auth/logout')
|
|
.set('Cookie', 'refreshToken=some-valid-token');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.message).toBe('Logged out successfully.');
|
|
|
|
// Check that the 'set-cookie' header is trying to expire the cookie
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
expect(setCookieHeader).toBeDefined();
|
|
expect(setCookieHeader[0]).toContain('refreshToken=;');
|
|
// Check for Max-Age=0, which is the modern way to expire a cookie.
|
|
// The 'Expires' attribute is a fallback and its exact value can be inconsistent.
|
|
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
|
});
|
|
|
|
it('should still return 200 OK and log an error if deleting the refresh token from DB fails', async () => {
|
|
// Arrange
|
|
const dbError = new Error('DB connection lost');
|
|
mockedAuthService.logout.mockRejectedValue(dbError);
|
|
const { logger } = await import('../services/logger.server');
|
|
|
|
// Spy on logger.error to ensure it's called
|
|
const errorSpy = vi.spyOn(logger, 'error');
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/auth/logout')
|
|
.set('Cookie', 'refreshToken=some-token');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
|
|
// Because authService.logout is fire-and-forget (not awaited), we need to
|
|
// give the event loop a moment to process the rejected promise and trigger the .catch() block.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: dbError }),
|
|
'Logout token invalidation failed in background.',
|
|
);
|
|
});
|
|
|
|
it('should return 200 OK and clear the cookie even if no refresh token is provided', async () => {
|
|
// Act: Make a request without a cookie.
|
|
const response = await supertest(app).post('/api/auth/logout');
|
|
|
|
// Assert: The response should still be successful and attempt to clear the cookie.
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /forgot-password', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const email = 'rate-limit-test@example.com';
|
|
const maxRequests = 5; // from the rate limiter config
|
|
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
|
|
|
|
// Act: Make `maxRequests` successful calls with the special header
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter for this test
|
|
.send({ email });
|
|
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
|
}
|
|
|
|
// Act: Make one more call, which should be blocked
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send({ email });
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many password reset requests');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
|
|
// Arrange
|
|
const email = 'no-rate-limit-test@example.com';
|
|
const overLimitRequests = 7; // More than the max of 5
|
|
mockedAuthService.resetPassword.mockResolvedValue('mock-token');
|
|
|
|
// Act: Make more calls than the limit. They should all succeed because the limiter is skipped.
|
|
for (let i = 0; i < overLimitRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/forgot-password')
|
|
// NO 'X-Test-Rate-Limit-Enable' header is sent
|
|
.send({ email });
|
|
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /reset-password', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const maxRequests = 10; // from the rate limiter config in auth.routes.ts
|
|
const newPassword = 'a-Very-Strong-Password-123!';
|
|
const token = 'some-token-for-rate-limit-test';
|
|
|
|
// Mock the service to return a consistent value for the first `maxRequests` calls.
|
|
// The endpoint returns 400 for invalid tokens, which is fine for this test.
|
|
// We just need to ensure it's not a 429.
|
|
mockedAuthService.updatePassword.mockResolvedValue(null);
|
|
|
|
// Act: Make `maxRequests` calls. They should not be rate-limited.
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.set('X-Test-Rate-Limit-Enable', 'true') // Opt-in to the rate limiter
|
|
.send({ token, newPassword });
|
|
// The expected status is 400 because the token is invalid, but not 429.
|
|
expect(response.status, `Request ${i + 1} should not be rate-limited`).toBe(400);
|
|
}
|
|
|
|
// Act: Make one more call, which should be blocked by the rate limiter.
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send({ token, newPassword });
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many password reset attempts');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
|
|
// Arrange
|
|
const maxRequests = 12; // Limit is 10
|
|
const newPassword = 'a-Very-Strong-Password-123!';
|
|
const token = 'some-token-for-skip-limit-test';
|
|
|
|
mockedAuthService.updatePassword.mockResolvedValue(null);
|
|
|
|
// Act: Make more calls than the limit.
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/reset-password')
|
|
.send({ token, newPassword });
|
|
expect(response.status).toBe(400);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /register', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const maxRequests = 5; // Limit is 5 per hour
|
|
const newUser = {
|
|
email: 'rate-limit-reg@test.com',
|
|
password: 'StrongPassword123!',
|
|
full_name: 'Rate Limit User',
|
|
};
|
|
|
|
// Mock success to ensure we are hitting the limiter and not failing early
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
|
|
accessToken: 'token',
|
|
refreshToken: 'refresh',
|
|
});
|
|
|
|
// Act: Make maxRequests calls
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send(newUser);
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
|
|
// Act: Make one more call
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/register')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send(newUser);
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many accounts created');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
|
const maxRequests = 7;
|
|
const newUser = {
|
|
email: 'no-limit-reg@test.com',
|
|
password: 'StrongPassword123!',
|
|
full_name: 'No Limit User',
|
|
};
|
|
|
|
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
|
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
|
|
accessToken: 'token',
|
|
refreshToken: 'refresh',
|
|
});
|
|
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app).post('/api/auth/register').send(newUser);
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /login', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const maxRequests = 5; // Limit is 5 per 15 mins
|
|
const credentials = { email: 'rate-limit-login@test.com', password: 'password123' };
|
|
|
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
|
accessToken: 'token',
|
|
refreshToken: 'refresh',
|
|
});
|
|
|
|
// Act
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send(credentials);
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/login')
|
|
.set('X-Test-Rate-Limit-Enable', 'true')
|
|
.send(credentials);
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many login attempts');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
|
const maxRequests = 7;
|
|
const credentials = { email: 'no-limit-login@test.com', password: 'password123' };
|
|
|
|
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
|
|
accessToken: 'token',
|
|
refreshToken: 'refresh',
|
|
});
|
|
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app).post('/api/auth/login').send(credentials);
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /refresh-token', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const maxRequests = 20; // Limit is 20 per 15 mins
|
|
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
|
|
|
|
// Act: Make maxRequests calls
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=valid-token')
|
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
|
|
// Act: Make one more call
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=valid-token')
|
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many token refresh attempts');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
|
const maxRequests = 22;
|
|
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
|
|
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/refresh-token')
|
|
.set('Cookie', 'refreshToken=valid-token');
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting on /logout', () => {
|
|
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
|
|
// Arrange
|
|
const maxRequests = 10; // Limit is 10 per 15 mins
|
|
mockedAuthService.logout.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/logout')
|
|
.set('Cookie', 'refreshToken=valid-token')
|
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
|
|
const blockedResponse = await supertest(app)
|
|
.post('/api/auth/logout')
|
|
.set('Cookie', 'refreshToken=valid-token')
|
|
.set('X-Test-Rate-Limit-Enable', 'true');
|
|
|
|
// Assert
|
|
expect(blockedResponse.status).toBe(429);
|
|
expect(blockedResponse.text).toContain('Too many logout attempts');
|
|
});
|
|
|
|
it('should NOT block requests when the opt-in header is not sent', async () => {
|
|
const maxRequests = 12;
|
|
mockedAuthService.logout.mockResolvedValue(undefined);
|
|
|
|
for (let i = 0; i < maxRequests; i++) {
|
|
const response = await supertest(app)
|
|
.post('/api/auth/logout')
|
|
.set('Cookie', 'refreshToken=valid-token');
|
|
expect(response.status).not.toBe(429);
|
|
}
|
|
});
|
|
});
|
|
});
|