Skip to content

Commit a44e2eb

Browse files
Merge pull request #3175 from pierreb-devkit/test/auth-service-unit-tests
test(auth): add unit tests for auth.service and fix passport local strategy done() call
2 parents c9b2d83 + f78367b commit a44e2eb

File tree

3 files changed

+195
-2
lines changed

3 files changed

+195
-2
lines changed

modules/auth/config/strategies/local.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import passport from 'passport';
55
import { Strategy } from 'passport-local';
66

7+
import AppError from '../../../../lib/helpers/AppError.js';
78
import AuthService from '../../services/auth.service.js';
89

910
export default () => {
@@ -21,8 +22,11 @@ export default () => {
2122
return done(null, false, {
2223
message: 'Invalid email or password',
2324
});
24-
} catch (_err) {
25-
return done();
25+
} catch (err) {
26+
if (err instanceof AppError && err.code === 'SERVICE_ERROR') {
27+
return done(null, false, { message: 'Invalid email or password' });
28+
}
29+
return done(err);
2630
}
2731
},
2832
),

modules/auth/tests/auth.integration.tests.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import config from '../../../config/index.js';
1616
*/
1717
describe('Auth integration tests:', () => {
1818
let UserService = null;
19+
let AuthService = null;
1920
let agent;
2021
let credentials;
2122
let user;
@@ -28,6 +29,7 @@ describe('Auth integration tests:', () => {
2829
try {
2930
const init = await bootstrap();
3031
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
32+
AuthService = (await import(path.resolve('./modules/auth/services/auth.service.js'))).default;
3133
agent = request.agent(init.app);
3234
} catch (err) {
3335
console.log(err);
@@ -794,6 +796,12 @@ describe('Auth integration tests:', () => {
794796
const result = await agent.get('/api/auth/reset/sometoken').expect(302);
795797
expect(result.headers.location).toBe('/api/password/reset/invalid');
796798
});
799+
800+
test('should return 500 when local strategy authenticate throws an unexpected error', async () => {
801+
const spy = jest.spyOn(AuthService, 'authenticate').mockRejectedValueOnce(new Error('DB failure'));
802+
await agent.post('/api/auth/signin').send({ email: 'a@b.com', password: 'pass' }).expect(500);
803+
spy.mockRestore();
804+
});
797805
});
798806

799807
// Mongoose disconnect
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest } from '@jest/globals';
5+
6+
/**
7+
* Mocks — must be declared before dynamic imports of the module under test.
8+
* jest.unstable_mockModule is the correct API for ES module mocking.
9+
*/
10+
const mockGet = jest.fn();
11+
jest.unstable_mockModule('../../users/repositories/user.repository.js', () => ({
12+
default: { get: mockGet },
13+
}));
14+
15+
const mockBcryptCompare = jest.fn();
16+
const mockBcryptHash = jest.fn();
17+
jest.unstable_mockModule('bcrypt', () => ({
18+
default: {
19+
compare: (...args) => mockBcryptCompare(...args),
20+
hash: (...args) => mockBcryptHash(...args),
21+
},
22+
}));
23+
24+
const mockZxcvbn = jest.fn();
25+
jest.unstable_mockModule('zxcvbn', () => ({ default: (...args) => mockZxcvbn(...args) }));
26+
27+
jest.unstable_mockModule('../../../config/index.js', () => ({
28+
default: {
29+
whitelists: { users: { default: ['_id', 'id', 'firstName', 'lastName', 'email', 'roles', 'provider'] } },
30+
zxcvbn: { minimumScore: 3 },
31+
},
32+
}));
33+
34+
// Dynamic import after mocks are registered
35+
const { default: AuthService } = await import('../services/auth.service.js');
36+
37+
/**
38+
* Unit tests
39+
*/
40+
describe('Auth service unit tests:', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
// ---------------------------------------------------------------------------
46+
// removeSensitive
47+
// ---------------------------------------------------------------------------
48+
describe('removeSensitive()', () => {
49+
test('should return null when user is null', () => {
50+
expect(AuthService.removeSensitive(null)).toBeNull();
51+
});
52+
53+
test('should return null when user is not an object', () => {
54+
expect(AuthService.removeSensitive('string')).toBeNull();
55+
});
56+
57+
test('should strip fields not in the whitelist', () => {
58+
const user = { _id: '1', email: 'a@b.com', password: 'secret', roles: ['user'] };
59+
const result = AuthService.removeSensitive(user);
60+
expect(result.email).toBe('a@b.com');
61+
expect(result.password).toBeUndefined();
62+
});
63+
64+
test('should pick only the provided custom keys when conf is supplied', () => {
65+
const user = { _id: '1', email: 'a@b.com', firstName: 'Joe', roles: ['user'] };
66+
const result = AuthService.removeSensitive(user, ['email']);
67+
expect(result).toEqual({ email: 'a@b.com' });
68+
});
69+
});
70+
71+
// ---------------------------------------------------------------------------
72+
// comparePassword
73+
// ---------------------------------------------------------------------------
74+
describe('comparePassword()', () => {
75+
test('should return true when passwords match', async () => {
76+
mockBcryptCompare.mockResolvedValueOnce(true);
77+
const result = await AuthService.comparePassword('plain', 'hashed');
78+
expect(result).toBe(true);
79+
expect(mockBcryptCompare).toHaveBeenCalledWith('plain', 'hashed');
80+
});
81+
82+
test('should return false when passwords do not match', async () => {
83+
mockBcryptCompare.mockResolvedValueOnce(false);
84+
const result = await AuthService.comparePassword('wrong', 'hashed');
85+
expect(result).toBe(false);
86+
});
87+
88+
test('should coerce arguments to strings before comparing', async () => {
89+
mockBcryptCompare.mockResolvedValueOnce(true);
90+
await AuthService.comparePassword(12345, 67890);
91+
expect(mockBcryptCompare).toHaveBeenCalledWith('12345', '67890');
92+
});
93+
});
94+
95+
// ---------------------------------------------------------------------------
96+
// hashPassword
97+
// ---------------------------------------------------------------------------
98+
describe('hashPassword()', () => {
99+
test('should resolve with the hashed string', async () => {
100+
mockBcryptHash.mockResolvedValueOnce('$2b$hashed');
101+
const result = await AuthService.hashPassword('mypassword');
102+
expect(result).toBe('$2b$hashed');
103+
expect(mockBcryptHash).toHaveBeenCalledWith('mypassword', 10);
104+
});
105+
106+
test('should coerce the password to a string', async () => {
107+
mockBcryptHash.mockResolvedValueOnce('$2b$hashed');
108+
await AuthService.hashPassword(42);
109+
expect(mockBcryptHash).toHaveBeenCalledWith('42', 10);
110+
});
111+
});
112+
113+
// ---------------------------------------------------------------------------
114+
// authenticate
115+
// ---------------------------------------------------------------------------
116+
describe('authenticate()', () => {
117+
test('should throw when user is not found', async () => {
118+
mockGet.mockResolvedValueOnce(null);
119+
await expect(AuthService.authenticate('a@b.com', 'pass')).rejects.toThrow('invalid user or password.');
120+
});
121+
122+
test('should return sanitised user when credentials are valid', async () => {
123+
const storedUser = { _id: '1', email: 'a@b.com', firstName: 'Joe', password: 'hashed', roles: ['user'], provider: 'local' };
124+
mockGet.mockResolvedValueOnce(storedUser);
125+
mockBcryptCompare.mockResolvedValueOnce(true);
126+
const result = await AuthService.authenticate('a@b.com', 'plain');
127+
expect(result.email).toBe('a@b.com');
128+
expect(result.password).toBeUndefined();
129+
});
130+
131+
test('should throw when password does not match', async () => {
132+
mockGet.mockResolvedValueOnce({ email: 'a@b.com', password: 'hashed' });
133+
mockBcryptCompare.mockResolvedValueOnce(false);
134+
await expect(AuthService.authenticate('a@b.com', 'wrong')).rejects.toThrow('invalid user or password.');
135+
});
136+
});
137+
138+
// ---------------------------------------------------------------------------
139+
// checkPassword
140+
// ---------------------------------------------------------------------------
141+
describe('checkPassword()', () => {
142+
test('should return the password when score meets the minimum', () => {
143+
mockZxcvbn.mockReturnValueOnce({ score: 3, feedback: { suggestions: [] } });
144+
expect(AuthService.checkPassword('StrongPass1!')).toBe('StrongPass1!');
145+
});
146+
147+
test('should throw AppError when score is below minimum', () => {
148+
mockZxcvbn.mockReturnValueOnce({ score: 1, feedback: { suggestions: ['Add more characters.'] } });
149+
expect(() => AuthService.checkPassword('weak')).toThrow('Password too weak.');
150+
});
151+
152+
test('error details should include mapped suggestion objects', () => {
153+
mockZxcvbn.mockReturnValueOnce({ score: 2, feedback: { suggestions: ['Use a mix of letters.', 'Avoid common words.'] } });
154+
let thrownError;
155+
try {
156+
AuthService.checkPassword('medium');
157+
} catch (err) {
158+
thrownError = err;
159+
}
160+
expect(thrownError).toBeDefined();
161+
expect(thrownError.details).toEqual([{ message: 'Use a mix of letters.' }, { message: 'Avoid common words.' }]);
162+
});
163+
});
164+
165+
// ---------------------------------------------------------------------------
166+
// generateRandomPassphrase
167+
// ---------------------------------------------------------------------------
168+
describe('generateRandomPassphrase()', () => {
169+
test('should return a string that passes the strength check', () => {
170+
mockZxcvbn.mockReturnValue({ score: 4, feedback: { suggestions: [] } });
171+
const result = AuthService.generateRandomPassphrase();
172+
expect(typeof result).toBe('string');
173+
expect(result.length).toBeGreaterThanOrEqual(20);
174+
});
175+
176+
test('should throw when the generated password is too weak', () => {
177+
mockZxcvbn.mockReturnValue({ score: 0, feedback: { suggestions: ['Make it longer.'] } });
178+
expect(() => AuthService.generateRandomPassphrase()).toThrow('Password too weak.');
179+
});
180+
});
181+
});

0 commit comments

Comments
 (0)