Files
flyer-crawler.projectium.com/src/routes/auth.routes.test.ts
Torben Sorensen b7f3182fd6
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 4m24s
clean up routes
2025-12-29 13:34:26 -08:00

550 lines
20 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
// --- 4. App Setup ---
// We need to inject cookie-parser BEFORE the router is mounted.
// Since createTestApp mounts the router immediately, we pass middleware to it if supported,
// or we construct the app manually here to ensure correct order.
// Assuming createTestApp doesn't support pre-middleware injection easily, we will
// create a standard express app here for full control, or modify createTestApp usage if possible.
// Looking at createTestApp.ts (inferred), it likely doesn't take middleware.
// Let's manually build the app for this test file to ensure cookieParser runs first.
import express from 'express';
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
const { mockLogger } = await import('../tests/utils/mockLogger');
const app = express();
app.use(express.json());
app.use(cookieParser()); // Mount BEFORE router
// Middleware to inject the mock logger into req
app.use((req, res, next) => {
req.log = mockLogger;
next();
});
app.use('/api/auth', authRouter);
app.use(errorHandler); // Mount AFTER router
// --- 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.message).toBe('User registered successfully!');
expect(response.body.userprofile.user.email).toBe(newUserEmail);
expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed.
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
newUserEmail,
strongPassword,
'Test User',
undefined, // avatar_url
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.errors?.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.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.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.errors[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.errors[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.userprofile).toEqual(
expect.objectContaining({
user: expect.objectContaining({
user_id: 'user-123',
email: loginCredentials.email,
}),
}),
);
expect(response.body.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.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.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.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
});
});
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.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
expect(response.body.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.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.errors[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.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.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.errors[0].message).toMatch(/Token is required|Required/i);
});
});
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.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.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.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.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 even 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');
// Act
const response = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=some-token');
// Assert
expect(response.status).toBe(200);
expect(logger.error).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=;');
});
});
});