diff --git a/modules/auth/config/strategies/local.js b/modules/auth/config/strategies/local.js index fb5e6411c..425113d97 100644 --- a/modules/auth/config/strategies/local.js +++ b/modules/auth/config/strategies/local.js @@ -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 () => { @@ -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); } }, ), diff --git a/modules/auth/tests/auth.integration.tests.js b/modules/auth/tests/auth.integration.tests.js index 354a8c988..d33ea74e7 100644 --- a/modules/auth/tests/auth.integration.tests.js +++ b/modules/auth/tests/auth.integration.tests.js @@ -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; @@ -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); @@ -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 diff --git a/modules/auth/tests/auth.unit.tests.js b/modules/auth/tests/auth.unit.tests.js new file mode 100644 index 000000000..724e32dc9 --- /dev/null +++ b/modules/auth/tests/auth.unit.tests.js @@ -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.'); + }); + }); +});