Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions modules/auth/config/strategies/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import passport from 'passport';
import { Strategy } from 'passport-local';

import AppError from '../../../../lib/helpers/AppError.js';
import AuthService from '../../services/auth.service.js';

export default () => {
Expand All @@ -21,8 +22,11 @@ export default () => {
return done(null, false, {
message: 'Invalid email or password',
});
} catch (_err) {
return done();
} catch (err) {
if (err instanceof AppError && err.code === 'SERVICE_ERROR') {
return done(null, false, { message: 'Invalid email or password' });
}
return done(err);
}
},
),
Expand Down
8 changes: 8 additions & 0 deletions modules/auth/tests/auth.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import config from '../../../config/index.js';
*/
describe('Auth integration tests:', () => {
let UserService = null;
let AuthService = null;
let agent;
let credentials;
let user;
Expand All @@ -28,6 +29,7 @@ describe('Auth integration tests:', () => {
try {
const init = await bootstrap();
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
AuthService = (await import(path.resolve('./modules/auth/services/auth.service.js'))).default;
agent = request.agent(init.app);
} catch (err) {
console.log(err);
Expand Down Expand Up @@ -794,6 +796,12 @@ describe('Auth integration tests:', () => {
const result = await agent.get('/api/auth/reset/sometoken').expect(302);
expect(result.headers.location).toBe('/api/password/reset/invalid');
});

test('should return 500 when local strategy authenticate throws an unexpected error', async () => {
const spy = jest.spyOn(AuthService, 'authenticate').mockRejectedValueOnce(new Error('DB failure'));
await agent.post('/api/auth/signin').send({ email: 'a@b.com', password: 'pass' }).expect(500);
spy.mockRestore();
});
});

// Mongoose disconnect
Expand Down
181 changes: 181 additions & 0 deletions modules/auth/tests/auth.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Module dependencies.
*/
import { jest } from '@jest/globals';

/**
* Mocks — must be declared before dynamic imports of the module under test.
* jest.unstable_mockModule is the correct API for ES module mocking.
*/
const mockGet = jest.fn();
jest.unstable_mockModule('../../users/repositories/user.repository.js', () => ({
default: { get: mockGet },
}));

const mockBcryptCompare = jest.fn();
const mockBcryptHash = jest.fn();
jest.unstable_mockModule('bcrypt', () => ({
default: {
compare: (...args) => mockBcryptCompare(...args),
hash: (...args) => mockBcryptHash(...args),
},
}));

const mockZxcvbn = jest.fn();
jest.unstable_mockModule('zxcvbn', () => ({ default: (...args) => mockZxcvbn(...args) }));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: {
whitelists: { users: { default: ['_id', 'id', 'firstName', 'lastName', 'email', 'roles', 'provider'] } },
zxcvbn: { minimumScore: 3 },
},
}));

// Dynamic import after mocks are registered
const { default: AuthService } = await import('../services/auth.service.js');

/**
* Unit tests
*/
describe('Auth service unit tests:', () => {
beforeEach(() => {
jest.clearAllMocks();
});

// ---------------------------------------------------------------------------
// removeSensitive
// ---------------------------------------------------------------------------
describe('removeSensitive()', () => {
test('should return null when user is null', () => {
expect(AuthService.removeSensitive(null)).toBeNull();
});

test('should return null when user is not an object', () => {
expect(AuthService.removeSensitive('string')).toBeNull();
});

test('should strip fields not in the whitelist', () => {
const user = { _id: '1', email: 'a@b.com', password: 'secret', roles: ['user'] };
const result = AuthService.removeSensitive(user);
expect(result.email).toBe('a@b.com');
expect(result.password).toBeUndefined();
});

test('should pick only the provided custom keys when conf is supplied', () => {
const user = { _id: '1', email: 'a@b.com', firstName: 'Joe', roles: ['user'] };
const result = AuthService.removeSensitive(user, ['email']);
expect(result).toEqual({ email: 'a@b.com' });
});
});

// ---------------------------------------------------------------------------
// comparePassword
// ---------------------------------------------------------------------------
describe('comparePassword()', () => {
test('should return true when passwords match', async () => {
mockBcryptCompare.mockResolvedValueOnce(true);
const result = await AuthService.comparePassword('plain', 'hashed');
expect(result).toBe(true);
expect(mockBcryptCompare).toHaveBeenCalledWith('plain', 'hashed');
});

test('should return false when passwords do not match', async () => {
mockBcryptCompare.mockResolvedValueOnce(false);
const result = await AuthService.comparePassword('wrong', 'hashed');
expect(result).toBe(false);
});

test('should coerce arguments to strings before comparing', async () => {
mockBcryptCompare.mockResolvedValueOnce(true);
await AuthService.comparePassword(12345, 67890);
expect(mockBcryptCompare).toHaveBeenCalledWith('12345', '67890');
});
});

// ---------------------------------------------------------------------------
// hashPassword
// ---------------------------------------------------------------------------
describe('hashPassword()', () => {
test('should resolve with the hashed string', async () => {
mockBcryptHash.mockResolvedValueOnce('$2b$hashed');
const result = await AuthService.hashPassword('mypassword');
expect(result).toBe('$2b$hashed');
expect(mockBcryptHash).toHaveBeenCalledWith('mypassword', 10);
});

test('should coerce the password to a string', async () => {
mockBcryptHash.mockResolvedValueOnce('$2b$hashed');
await AuthService.hashPassword(42);
expect(mockBcryptHash).toHaveBeenCalledWith('42', 10);
});
});

// ---------------------------------------------------------------------------
// authenticate
// ---------------------------------------------------------------------------
describe('authenticate()', () => {
test('should throw when user is not found', async () => {
mockGet.mockResolvedValueOnce(null);
await expect(AuthService.authenticate('a@b.com', 'pass')).rejects.toThrow('invalid user or password.');
});

test('should return sanitised user when credentials are valid', async () => {
const storedUser = { _id: '1', email: 'a@b.com', firstName: 'Joe', password: 'hashed', roles: ['user'], provider: 'local' };
mockGet.mockResolvedValueOnce(storedUser);
mockBcryptCompare.mockResolvedValueOnce(true);
const result = await AuthService.authenticate('a@b.com', 'plain');
expect(result.email).toBe('a@b.com');
expect(result.password).toBeUndefined();
});

test('should throw when password does not match', async () => {
mockGet.mockResolvedValueOnce({ email: 'a@b.com', password: 'hashed' });
mockBcryptCompare.mockResolvedValueOnce(false);
await expect(AuthService.authenticate('a@b.com', 'wrong')).rejects.toThrow('invalid user or password.');
});
});

// ---------------------------------------------------------------------------
// checkPassword
// ---------------------------------------------------------------------------
describe('checkPassword()', () => {
test('should return the password when score meets the minimum', () => {
mockZxcvbn.mockReturnValueOnce({ score: 3, feedback: { suggestions: [] } });
expect(AuthService.checkPassword('StrongPass1!')).toBe('StrongPass1!');
});

test('should throw AppError when score is below minimum', () => {
mockZxcvbn.mockReturnValueOnce({ score: 1, feedback: { suggestions: ['Add more characters.'] } });
expect(() => AuthService.checkPassword('weak')).toThrow('Password too weak.');
});

test('error details should include mapped suggestion objects', () => {
mockZxcvbn.mockReturnValueOnce({ score: 2, feedback: { suggestions: ['Use a mix of letters.', 'Avoid common words.'] } });
let thrownError;
try {
AuthService.checkPassword('medium');
} catch (err) {
thrownError = err;
}
expect(thrownError).toBeDefined();
expect(thrownError.details).toEqual([{ message: 'Use a mix of letters.' }, { message: 'Avoid common words.' }]);
});
});

// ---------------------------------------------------------------------------
// generateRandomPassphrase
// ---------------------------------------------------------------------------
describe('generateRandomPassphrase()', () => {
test('should return a string that passes the strength check', () => {
mockZxcvbn.mockReturnValue({ score: 4, feedback: { suggestions: [] } });
const result = AuthService.generateRandomPassphrase();
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThanOrEqual(20);
});

test('should throw when the generated password is too weak', () => {
mockZxcvbn.mockReturnValue({ score: 0, feedback: { suggestions: ['Make it longer.'] } });
expect(() => AuthService.generateRandomPassphrase()).toThrow('Password too weak.');
});
});
});