339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { UserProfile } from '../types';
|
|
import type * as jsonwebtoken from 'jsonwebtoken';
|
|
|
|
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 UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
|
|
|
|
const reqLog = {}; // Mock request logger object
|
|
const mockUser = {
|
|
user_id: 'user-123',
|
|
email: 'test@example.com',
|
|
password_hash: 'hashed-password',
|
|
};
|
|
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
|
|
process.env.JWT_SECRET = 'test-secret';
|
|
process.env.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', () => ({
|
|
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;
|
|
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
|
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
|
});
|
|
|
|
describe('registerUser', () => {
|
|
it('should successfully register a new user', async () => {
|
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
|
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
|
|
|
const result = await authService.registerUser(
|
|
'test@example.com',
|
|
'password123',
|
|
'Test User',
|
|
undefined,
|
|
reqLog,
|
|
);
|
|
|
|
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
|
expect(userRepo.createUser).toHaveBeenCalledWith(
|
|
'test@example.com',
|
|
'hashed-password',
|
|
{ full_name: 'Test User', avatar_url: undefined },
|
|
reqLog,
|
|
);
|
|
expect(adminRepo.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(userRepo.createUser).mockRejectedValue(error);
|
|
|
|
await expect(
|
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
|
).rejects.toThrow(UniqueConstraintError);
|
|
|
|
expect(logger.error).not.toHaveBeenCalled(); // Should not log expected unique constraint errors as system errors
|
|
});
|
|
|
|
it('should log and throw other errors', async () => {
|
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
|
const error = new Error('Database failed');
|
|
vi.mocked(userRepo.createUser).mockRejectedValue(error);
|
|
|
|
await expect(
|
|
authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
|
|
).rejects.toThrow('Database failed');
|
|
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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(userRepo.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 log and throw error on failure', async () => {
|
|
const error = new Error('DB Error');
|
|
vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);
|
|
|
|
await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
|
|
'DB Error',
|
|
);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error }),
|
|
expect.stringContaining('Failed to save refresh token'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('resetPassword', () => {
|
|
it('should process password reset for existing user', async () => {
|
|
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser as any);
|
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
|
|
|
|
const result = await authService.resetPassword('test@example.com', reqLog);
|
|
|
|
expect(userRepo.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', 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); // Match found
|
|
vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');
|
|
|
|
const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);
|
|
|
|
expect(userRepo.updateUserPassword).toHaveBeenCalledWith(
|
|
'user-123',
|
|
'new-hashed-password',
|
|
reqLog,
|
|
);
|
|
expect(userRepo.deleteResetToken).toHaveBeenCalledWith('hashed-token', reqLog);
|
|
expect(adminRepo.logActivity).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'password_reset' }),
|
|
reqLog,
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
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(userRepo.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' } as any);
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('should delete refresh token', async () => {
|
|
await authService.logout('token', reqLog);
|
|
expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
|
|
});
|
|
|
|
it('should log and throw on error', async () => {
|
|
const error = new Error('DB Error');
|
|
vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);
|
|
|
|
await expect(authService.logout('token', reqLog)).rejects.toThrow('DB Error');
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('refreshAccessToken', () => {
|
|
it('should return new access token if user found', async () => {
|
|
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
|
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();
|
|
});
|
|
});
|
|
}); |