All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m55s
432 lines
17 KiB
TypeScript
432 lines
17 KiB
TypeScript
// 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);
|
|
});
|
|
});
|
|
}); |