// src/services/authService.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { UserProfile } from '../types'; import type * as jsonwebtoken from 'jsonwebtoken'; const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted(() => { return { transactionalUserRepoMocks: { updateUserPassword: vi.fn(), deleteResetToken: vi.fn(), createPasswordResetToken: vi.fn(), createUser: vi.fn(), }, transactionalAdminRepoMocks: { logActivity: vi.fn(), }, }; }); vi.mock('./db/user.db', () => ({ UserRepository: vi.fn().mockImplementation(function () { return transactionalUserRepoMocks }), })); vi.mock('./db/admin.db', () => ({ AdminRepository: vi.fn().mockImplementation(function () { return transactionalAdminRepoMocks }), })); describe('AuthService', () => { let authService: typeof import('./authService').authService; let bcrypt: typeof import('bcrypt'); let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken }; let userRepo: typeof import('./db/index.db').userRepo; let adminRepo: typeof import('./db/index.db').adminRepo; let logger: typeof import('./logger.server').logger; let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail; let DatabaseError: typeof import('./processingErrors').DatabaseError; let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError; let RepositoryError: typeof import('./db/errors.db').RepositoryError; let withTransaction: typeof import('./db/index.db').withTransaction; const reqLog = {}; // Mock request logger object const mockUser = { user_id: 'user-123', email: 'test@example.com', password_hash: 'hashed-password', failed_login_attempts: 0, last_failed_login: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), refresh_token: null, }; const mockUserProfile: UserProfile = { user: mockUser, role: 'user', } as unknown as UserProfile; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); // Set environment variables before any modules are imported vi.stubEnv('JWT_SECRET', 'test-secret'); vi.stubEnv('FRONTEND_URL', 'http://localhost:3000'); // Mock all dependencies before dynamically importing the service // Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts vi.mock('bcrypt'); vi.mock('./db/index.db', () => ({ withTransaction: vi.fn(), userRepo: { createUser: vi.fn(), saveRefreshToken: vi.fn(), findUserByEmail: vi.fn(), createPasswordResetToken: vi.fn(), getValidResetTokens: vi.fn(), updateUserPassword: vi.fn(), deleteResetToken: vi.fn(), findUserByRefreshToken: vi.fn(), findUserProfileById: vi.fn(), deleteRefreshToken: vi.fn(), }, adminRepo: { logActivity: vi.fn(), }, })); vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); vi.mock('./emailService.server', () => ({ sendPasswordResetEmail: vi.fn(), })); vi.mock('./db/connection.db', () => ({ getPool: vi.fn() })); vi.mock('../utils/authUtils', () => ({ validatePasswordStrength: vi.fn() })); // Dynamically import modules to get the mocked versions and the service instance authService = (await import('./authService')).authService; bcrypt = await import('bcrypt'); jwt = (await import('jsonwebtoken')) as typeof jwt; const dbModule = await import('./db/index.db'); userRepo = dbModule.userRepo; adminRepo = dbModule.adminRepo; logger = (await import('./logger.server')).logger; withTransaction = (await import('./db/index.db')).withTransaction; vi.mocked(withTransaction).mockImplementation(async (callback: any) => { return callback({}); // Mock client }); const { validatePasswordStrength } = await import('../utils/authUtils'); vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: true, feedback: '' }); sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail; DatabaseError = (await import('./processingErrors')).DatabaseError; UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError; RepositoryError = (await import('./db/errors.db')).RepositoryError; }); afterEach(() => { vi.unstubAllEnvs(); }); describe('registerUser', () => { it('should successfully register a new user', async () => { vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password'); vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile); const result = await authService.registerUser( 'test@example.com', 'password123', 'Test User', undefined, reqLog, ); expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10); expect(transactionalUserRepoMocks.createUser).toHaveBeenCalledWith( 'test@example.com', 'hashed-password', { full_name: 'Test User', avatar_url: undefined }, reqLog, ); expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith( expect.objectContaining({ action: 'user_registered', userId: 'user-123', }), reqLog, ); expect(result).toEqual(mockUserProfile); }); it('should throw UniqueConstraintError if email already exists', async () => { vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password'); const error = new UniqueConstraintError('Email exists'); vi.mocked(withTransaction).mockRejectedValue(error); await expect( authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog), ).rejects.toThrow(UniqueConstraintError); expect(logger.error).not.toHaveBeenCalled(); }); it('should log and re-throw generic errors on registration failure', async () => { vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password'); const error = new Error('Database failed'); vi.mocked(withTransaction).mockRejectedValue(error); await expect( authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog), ).rejects.toThrow(DatabaseError); expect(logger.error).toHaveBeenCalledWith({ error, email: 'test@example.com' }, `User registration failed with an unexpected error.`); }); }); describe('registerAndLoginUser', () => { it('should register user and return tokens', async () => { // Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls) vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password'); vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile); // FIX: The global mock for jsonwebtoken provides a `default` export. // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export. // We must mock `jwt.default.sign` to affect the code under test. vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token'); const result = await authService.registerAndLoginUser( 'test@example.com', 'password123', 'Test User', undefined, reqLog, ); expect(result).toEqual({ newUserProfile: mockUserProfile, accessToken: 'access-token', refreshToken: 'mocked_random_id', }); expect(userRepo.saveRefreshToken).toHaveBeenCalledWith( 'user-123', 'mocked_random_id', reqLog, ); }); }); describe('generateAuthTokens', () => { it('should generate access and refresh tokens', () => { // FIX: The global mock for jsonwebtoken provides a `default` export. // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export. // We must mock `jwt.default.sign` to affect the code under test. vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token'); const result = authService.generateAuthTokens(mockUserProfile); expect(vi.mocked(jwt.default.sign)).toHaveBeenCalledWith( { user_id: 'user-123', email: 'test@example.com', role: 'user', }, 'test-secret', { expiresIn: '15m' }, ); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'mocked_random_id', }); }); }); describe('saveRefreshToken', () => { it('should save refresh token to db', async () => { await authService.saveRefreshToken('user-123', 'token', reqLog); expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog); }); it('should propagate the error from the repository on failure', async () => { const error = new Error('DB Error'); vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error); // The service method now directly propagates the error from the repo. await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(error); expect(logger.error).not.toHaveBeenCalled(); }); }); describe('resetPassword', () => { it('should process password reset for existing user', async () => { vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser); vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token'); const result = await authService.resetPassword('test@example.com', reqLog); expect(transactionalUserRepoMocks.createPasswordResetToken).toHaveBeenCalledWith( 'user-123', 'hashed-token', expect.any(Date), reqLog, {}, ); expect(sendPasswordResetEmail).toHaveBeenCalledWith( 'test@example.com', expect.stringContaining('/reset-password/mocked_random_id'), reqLog, ); expect(result).toBe('mocked_random_id'); }); it('should log warning and return undefined for non-existent user', async () => { vi.mocked(userRepo.findUserByEmail).mockResolvedValue(undefined); const result = await authService.resetPassword('unknown@example.com', reqLog); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('Password reset requested for non-existent email'), ); expect(sendPasswordResetEmail).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); it('should log error and throw on failure', async () => { const error = new Error('DB Error'); vi.mocked(userRepo.findUserByEmail).mockRejectedValue(error); await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow( 'DB Error', ); expect(logger.error).toHaveBeenCalled(); }); }); describe('updatePassword', () => { it('should update password if token is valid and wrap operations in a transaction', async () => { const mockTokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', }; vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any); vi.mocked(bcrypt.compare).mockImplementation(async () => true); vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password'); const result = await authService.updatePassword('valid-token', 'newPassword', reqLog); expect(withTransaction).toHaveBeenCalledTimes(1); expect(transactionalUserRepoMocks.updateUserPassword).toHaveBeenCalledWith( 'user-123', 'new-hashed-password', reqLog, ); expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog); expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith( expect.objectContaining({ action: 'password_reset' }), reqLog, ); expect(result).toBe(true); }); it('should log and re-throw an error if the transaction fails', async () => { const mockTokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' }; vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any); vi.mocked(bcrypt.compare).mockImplementation(async () => true); const dbError = new Error('Transaction failed'); vi.mocked(withTransaction).mockRejectedValue(dbError); await expect(authService.updatePassword('valid-token', 'newPassword', reqLog)).rejects.toThrow(DatabaseError); expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `An unexpected error occurred during password update.`); }); it('should return null if token is invalid or not found', async () => { vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]); const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog); expect(transactionalUserRepoMocks.updateUserPassword).not.toHaveBeenCalled(); expect(result).toBeNull(); }); }); describe('getUserByRefreshToken', () => { it('should return user profile if token exists', async () => { vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile); const result = await authService.getUserByRefreshToken('valid-token', reqLog); expect(result).toEqual(mockUserProfile); }); it('should return null if token not found', async () => { vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined); const result = await authService.getUserByRefreshToken('invalid-token', reqLog); expect(result).toBeNull(); }); it('should throw a DatabaseError if finding the user fails with a generic error', async () => { const dbError = new Error('DB connection failed'); vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(dbError); // Use a try-catch to assert on the error instance properties, which is more robust // than `toBeInstanceOf` in some complex module mocking scenarios in Vitest. try { await authService.getUserByRefreshToken('any-token', reqLog); expect.fail('Expected an error to be thrown'); } catch (error: any) { expect(error.name).toBe('DatabaseError'); expect(error.message).toBe('DB connection failed'); expect(logger.error).toHaveBeenCalledWith( { error: dbError, refreshToken: 'any-token' }, 'An unexpected error occurred while fetching user by refresh token.', ); } }); it('should re-throw a RepositoryError if finding the user fails with a known error', async () => { const repoError = new RepositoryError('Some repo error', 500); vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(repoError); await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(repoError); // The original error is re-thrown, so the generic wrapper log should not be called. expect(logger.error).not.toHaveBeenCalledWith( expect.any(Object), 'An unexpected error occurred while fetching user by refresh token.', ); }); }); describe('logout', () => { it('should delete refresh token', async () => { await authService.logout('token', reqLog); expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog); }); it('should propagate the error from the repository on failure', async () => { const error = new Error('DB Error'); vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error); await expect(authService.logout('token', reqLog)).rejects.toThrow(error); expect(logger.error).not.toHaveBeenCalled(); }); }); describe('refreshAccessToken', () => { it('should return new access token if user found', async () => { vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123', email: 'test@example.com', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile); // FIX: The global mock for jsonwebtoken provides a `default` export. // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export. // We must mock `jwt.default.sign` to affect the code under test. vi.mocked(jwt.default.sign).mockImplementation(() => 'new-access-token'); const result = await authService.refreshAccessToken('valid-token', reqLog); expect(result).toEqual({ accessToken: 'new-access-token' }); }); it('should return null if user not found', async () => { vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined); const result = await authService.refreshAccessToken('invalid-token', reqLog); expect(result).toBeNull(); }); it('should propagate errors from getUserByRefreshToken', async () => { const dbError = new DatabaseError('Underlying DB call failed'); // We mock the service's own method since refreshAccessToken calls it directly. vi.spyOn(authService, 'getUserByRefreshToken').mockRejectedValue(dbError); await expect(authService.refreshAccessToken('any-token', reqLog)).rejects.toThrow(dbError); }); }); });