// 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'; import * as bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { createMockUserProfile, createMockUserWithPasswordHash, } from '../tests/utils/mockFactories'; import { mockLogger } from '../tests/utils/mockLogger'; // --- 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, 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 DB connection pool to control transactional behavior const { mockPool } = vi.hoisted(() => { const client = { query: vi.fn(), release: vi.fn(), }; return { mockPool: { connect: vi.fn(() => Promise.resolve(client)), }, mockClient: client, }; }); // Mock the Service Layer directly. // We use async import inside the factory to properly hoist the UniqueConstraintError class usage. vi.mock('../services/db/index.db', async () => { const { UniqueConstraintError } = await import('../services/db/errors.db'); return { userRepo: { findUserByEmail: vi.fn(), createUser: vi.fn(), saveRefreshToken: vi.fn(), createPasswordResetToken: vi.fn(), getValidResetTokens: vi.fn(), updateUserPassword: vi.fn(), deleteResetToken: vi.fn(), findUserByRefreshToken: vi.fn(), deleteRefreshToken: vi.fn(), }, adminRepo: { logActivity: vi.fn(), }, UniqueConstraintError: UniqueConstraintError, }; }); vi.mock('../services/db/connection.db', () => ({ getPool: () => mockPool, })); // Mock the logger vi.mock('../services/logger.server', () => ({ logger: mockLogger, })); // Mock the email service vi.mock('../services/emailService.server', () => ({ sendPasswordResetEmail: vi.fn(), })); // Mock bcrypt vi.mock('bcrypt', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, compare: vi.fn() }; }); // Import the router AFTER mocks are established import authRouter from './auth.routes'; import * as db from '../services/db/index.db'; // This was a duplicate, fixed. 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 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', }); // FIX: Mock the method on the imported singleton instance `userRepo` directly, // as this is what the route handler uses. Spying on the prototype does not // affect this already-created instance. vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser); vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined); // 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(db.userRepo.createUser).toHaveBeenCalled(); }); it('should set a refresh token cookie on successful registration', async () => { const mockNewUser = createMockUserProfile({ user: { user_id: 'new-user-id', email: 'cookie@test.com' }, }); vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser); vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined); 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 email already exists', 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'; vi.mocked(db.userRepo.createUser).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.'); expect(db.userRepo.createUser).toHaveBeenCalled(); }); it('should return 500 if a generic database error occurs during registration', async () => { const dbError = new Error('DB connection lost'); vi.mocked(db.userRepo.createUser).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' }; vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); // 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 contain the correct payload in the JWT token', async () => { // Arrange const loginCredentials = { email: 'payload.test@test.com', password: 'password123' }; vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); // Act const response = await supertest(app).post('/api/auth/login').send(loginCredentials); // Assert expect(response.status).toBe(200); const token = response.body.token; expect(token).toBeTypeOf('string'); const decodedPayload = jwt.decode(token) as { user_id: string; email: string; role: string }; expect(decodedPayload.user_id).toBe('user-123'); expect(decodedPayload.email).toBe(loginCredentials.email); expect(decodedPayload.role).toBe('user'); // Default role from mock factory }); 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' }; vi.mocked(db.userRepo.saveRefreshToken).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, }; vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined); // 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 vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue( createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }), ); vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined); // 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 () => { vi.mocked(db.userRepo.findUserByEmail).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 () => { vi.mocked(db.userRepo.findUserByEmail).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 still return 200 OK if the email service fails', async () => { // Arrange vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue( createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }), ); vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined); // Mock the email service to fail const { sendPasswordResetEmail } = await import('../services/emailService.server'); vi.mocked(sendPasswordResetEmail).mockRejectedValue(new Error('SMTP server down')); // Act const response = await supertest(app) .post('/api/auth/forgot-password') .send({ email: 'test@test.com' }); // Assert: The route should not fail even if the email does. expect(response.status).toBe(200); }); 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 () => { const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000), }; vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]); // This was a duplicate, fixed. vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Token matches vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined); vi.mocked(db.userRepo.deleteResetToken).mockResolvedValue(undefined); vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined); 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 () => { vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([]); // No valid tokens found 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 reject if token does not match any valid tokens in DB', async () => { const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000), }; vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]); vi.mocked(bcrypt.compare).mockResolvedValue(false as never); // Token does not match const response = await supertest(app) .post('/api/auth/reset-password') .send({ token: 'wrong-token', newPassword: 'a-Very-Strong-Password-123!' }); 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 () => { const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000), }; vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); 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 () => { const mockUser = createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com', }); vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser); 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 () => { // Mock finding no user for this token, which should trigger the 403 logic vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(undefined as any); 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 vi.mocked(db.userRepo.findUserByRefreshToken).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 vi.mocked(db.userRepo.deleteRefreshToken).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'); vi.mocked(db.userRepo.deleteRefreshToken).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 }), 'Failed to delete refresh token from DB during logout.', ); }); 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=;'); }); }); });