Skip to content

Commit 7f89dd4

Browse files
Merge pull request #691 from Marvell-Consulting/worktree-SW-1246-auth-tests
Expand test coverage for authentication routes (#565)
2 parents d7c8a19 + dea734d commit 7f89dd4

7 files changed

Lines changed: 320 additions & 37 deletions

File tree

jest.config.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,7 @@ const sharedConfig = {
88
// openid-client and its deps are now published as ESM and need transpiling to CJS
99
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose|nanoid)/)'],
1010
testEnvironment: 'node' as const,
11-
coveragePathIgnorePatterns: [
12-
'/node_modules',
13-
'/test/',
14-
'/src/migrations',
15-
'/src/controllers/auth.ts',
16-
'src/middleware/passport-auth.ts'
17-
]
11+
coveragePathIgnorePatterns: ['/node_modules', '/test/', '/src/migrations']
1812
};
1913

2014
const config: Config = {

src/controllers/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { UserDTO } from '../dtos/user/user-dto';
1414
const domain = new URL(config.auth.jwt.cookieDomain).hostname;
1515
logger.debug(`JWT cookie domain is '${domain}'`);
1616

17-
const checkTokenFitsInCookie = (token: string): void => {
17+
export const checkTokenFitsInCookie = (token: string): void => {
1818
const maxCookieSize = 4096; // Maximum size of a cookie in bytes
1919
const tokenSize = Buffer.byteLength(token, 'utf8');
2020

src/middleware/passport-auth.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,28 @@ const initEntraId = async (entraIdConfig: EntraIdConfig): Promise<void> => {
109109
callbackURL: `${config.backend.url}/auth/entraid/callback`
110110
};
111111

112-
const verify: VerifyFunction = async (tokens: Tokens, done: AuthenticateCallback) => {
112+
passport.use(AuthProvider.EntraId, new OpenIdStrategy(strategyOptions, entraIdVerify(openidConfig)));
113+
};
114+
115+
// Extracted as a named factory so the verify branches can be unit-tested without standing up a live
116+
// OIDC discovery. `openidConfig` is captured so userinfo can be fetched against the discovered provider.
117+
export const entraIdVerify =
118+
(openidConfig: OpenIdConfig): VerifyFunction =>
119+
async (tokens: Tokens, done: AuthenticateCallback) => {
113120
logger.debug('auth callback from entraid received');
114-
const { sub } = tokens.claims()!;
115121

116-
logger.debug('fetching user info from entraid...');
117-
const userInfo = await openIdClient.fetchUserInfo(openidConfig, tokens.access_token, sub);
122+
try {
123+
const { sub } = tokens.claims()!;
118124

119-
if (!userInfo?.sub || !userInfo?.email) {
120-
logger.warn('entraid auth failed: account is missing user id or email address and we need both');
121-
done(null, undefined, { message: 'entraid account does not have a user id or email, cannot login' });
122-
return;
123-
}
125+
logger.debug('fetching user info from entraid...');
126+
const userInfo = await openIdClient.fetchUserInfo(openidConfig, tokens.access_token, sub);
127+
128+
if (!userInfo?.sub || !userInfo?.email) {
129+
logger.warn('entraid auth failed: account is missing user id or email address and we need both');
130+
done(null, undefined, { message: 'entraid account does not have a user id or email, cannot login' });
131+
return;
132+
}
124133

125-
try {
126134
logger.debug('checking if user has previously logged in...');
127135

128136
const existingUserById = await UserRepository.findOne({
@@ -178,6 +186,3 @@ const initEntraId = async (entraIdConfig: EntraIdConfig): Promise<void> => {
178186
done(null, undefined, { message: 'Unknown error' });
179187
}
180188
};
181-
182-
passport.use(AuthProvider.EntraId, new OpenIdStrategy(strategyOptions, verify));
183-
};

test/integration/routes/auth.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import request from 'supertest';
2+
import jwt from 'jsonwebtoken';
23

34
import app from '../../../src/app';
45
import { initPassport } from '../../../src/middleware/passport-auth';
56
import { ensureWorkerDataSources, resetDatabase } from '../../helpers/reset-database';
67
import { config } from '../../../src/config';
8+
import { dbManager } from '../../../src/db/database-manager';
9+
import { UserDTO } from '../../../src/dtos/user/user-dto';
10+
import { UserGroup } from '../../../src/entities/user/user-group';
11+
import { UserGroupRole } from '../../../src/entities/user/user-group-role';
12+
import { GroupRole } from '../../../src/enums/group-role';
13+
import { Locale } from '../../../src/enums/locale';
14+
import { getTestUser, getTestUserGroup } from '../../helpers/get-test-user';
15+
import { getAuthHeader } from '../../helpers/auth-header';
716

817
// Need to mock blob storage as it is included in services middleware for every route
918
// avoids the "Jest did not exit one second after the test run has completed"
@@ -18,6 +27,8 @@ jest.mock('../../../src/services/blob-storage', () => {
1827
});
1928

2029
describe('Auth routes', () => {
30+
const callbackURL = `${config.frontend.url}/auth/callback`;
31+
2132
beforeAll(async () => {
2233
await ensureWorkerDataSources();
2334
await resetDatabase();
@@ -30,4 +41,102 @@ describe('Auth routes', () => {
3041
expect(res.status).toBe(200);
3142
expect(res.body).toEqual({ enabled: expectedProviders });
3243
});
44+
45+
describe('GET /auth/local (loginLocal)', () => {
46+
test('redirects to the frontend callback and sets a jwt cookie for a known user', async () => {
47+
const user = getTestUser('Local Success');
48+
await user.save();
49+
50+
const res = await request(app).get('/auth/local').query({ username: user.providerUserId });
51+
52+
expect(res.status).toBe(302);
53+
expect(res.headers.location).toBe(callbackURL);
54+
55+
const cookies = res.headers['set-cookie'] as unknown as string[];
56+
const jwtCookie = cookies.find((cookie) => cookie.startsWith('jwt='));
57+
expect(jwtCookie).toBeDefined();
58+
expect(jwtCookie).toContain('HttpOnly');
59+
});
60+
61+
test('redirects with error=login when no username is provided', async () => {
62+
const res = await request(app).get('/auth/local');
63+
expect(res.status).toBe(302);
64+
expect(res.headers.location).toBe(`${callbackURL}?error=login`);
65+
expect(res.headers['set-cookie']).toBeUndefined();
66+
});
67+
68+
test('redirects with error=login when the user does not exist', async () => {
69+
const res = await request(app).get('/auth/local').query({ username: 'no-such-user' });
70+
expect(res.status).toBe(302);
71+
expect(res.headers.location).toBe(`${callbackURL}?error=login`);
72+
expect(res.headers['set-cookie']).toBeUndefined();
73+
});
74+
});
75+
76+
// Exercises the JWT strategy branches in passport-auth.ts via the protected /healthcheck/jwt probe.
77+
// healthcheck.test.ts keeps a single 200 case as a smoke test of the route itself.
78+
describe('JWT auth middleware (passport-auth)', () => {
79+
test('/healthcheck/jwt returns 401 without a bearer token', async () => {
80+
const res = await request(app).get('/healthcheck/jwt');
81+
expect(res.status).toBe(401);
82+
});
83+
84+
test('/healthcheck/jwt returns 401 with an invalid bearer token', async () => {
85+
const res = await request(app).get('/healthcheck/jwt').set({ Authorization: 'Bearer this-is-not-a-token' });
86+
expect(res.status).toBe(401);
87+
});
88+
89+
test('/healthcheck/jwt returns 401 with a valid token for a user that does not exist', async () => {
90+
const unknownUser = getTestUser('Unknown JWT User');
91+
const res = await request(app).get('/healthcheck/jwt').set(getAuthHeader(unknownUser));
92+
expect(res.status).toBe(401);
93+
});
94+
95+
test('/healthcheck/jwt returns 401 for an expired token', async () => {
96+
const user = getTestUser('Expired Token User');
97+
await user.save();
98+
99+
const token = jwt.sign({ user: UserDTO.fromUser(user, Locale.English) }, config.auth.jwt.secret, {
100+
expiresIn: '-1s'
101+
});
102+
103+
const res = await request(app)
104+
.get('/healthcheck/jwt')
105+
.set({ Authorization: `Bearer ${token}` });
106+
expect(res.status).toBe(401);
107+
});
108+
109+
test('/healthcheck/jwt returns 401 when the user permissions have changed since the token was issued', async () => {
110+
const group = await dbManager
111+
.getPublisherDataSource()
112+
.getRepository(UserGroup)
113+
.save(getTestUserGroup('Perm Change Group'));
114+
115+
const user = getTestUser('Perm Change User');
116+
user.groupRoles = [UserGroupRole.create({ group, roles: [GroupRole.Editor] })];
117+
await user.save();
118+
119+
// token captures the user while they hold the Editor role
120+
const authHeader = getAuthHeader(user);
121+
122+
// revoke the role in the database so the live permissions no longer match the token
123+
await dbManager.getPublisherDataSource().getRepository(UserGroupRole).delete({ userId: user.id });
124+
125+
const res = await request(app).get('/healthcheck/jwt').set(authHeader);
126+
expect(res.status).toBe(401);
127+
});
128+
129+
test('/healthcheck/jwt returns 200 with a valid bearer token', async () => {
130+
const user = getTestUser('Valid JWT User');
131+
await user.save();
132+
133+
const res = await request(app).get('/healthcheck/jwt').set(getAuthHeader(user));
134+
135+
expect(res.status).toBe(200);
136+
expect(res.body).toEqual({
137+
message: 'success',
138+
user: UserDTO.fromUser(user, Locale.English)
139+
});
140+
});
141+
});
33142
});

test/integration/routes/healthcheck.test.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,23 +116,10 @@ describe('Healthcheck', () => {
116116
});
117117
});
118118

119+
// Smoke test that /healthcheck/jwt is wired to JWT auth and returns 200 for a valid user. The full
120+
// set of JWT strategy branches (missing / invalid / expired token, unknown user, changed permissions)
121+
// lives in test/integration/routes/auth.test.ts alongside the other passport-auth tests.
119122
describe('Authentication', () => {
120-
test('/heathcheck/jwt returns 401 without a bearer token', async () => {
121-
const res = await request(app).get('/healthcheck/jwt');
122-
expect(res.status).toBe(401);
123-
});
124-
125-
test('/heathcheck/jwt returns 401 with an invalid bearer token', async () => {
126-
const res = await request(app).get('/healthcheck/jwt').set({ Authorization: 'Bearer this-is-not-a-token' });
127-
expect(res.status).toBe(401);
128-
});
129-
130-
test('/heathcheck/jwt returns 401 with a valid bearer token but inactive user', async () => {
131-
const inactiveUser = getTestUser('Inactive User');
132-
const res = await request(app).get('/healthcheck/jwt').set(getAuthHeader(inactiveUser));
133-
expect(res.status).toBe(401);
134-
});
135-
136123
test('/heathcheck/jwt returns 200 with a valid bearer token', async () => {
137124
const testUser = getTestUser();
138125
await testUser.save();

test/unit/controllers/auth.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Request, Response } from 'express';
2+
import passport from 'passport';
3+
import jwt from 'jsonwebtoken';
4+
5+
import { checkTokenFitsInCookie, loginEntraID } from '../../../src/controllers/auth';
6+
import { config } from '../../../src/config';
7+
8+
describe('auth controller', () => {
9+
describe('checkTokenFitsInCookie', () => {
10+
it('does not throw for a token within the 4096 byte limit', () => {
11+
expect(() => checkTokenFitsInCookie('a'.repeat(4096))).not.toThrow();
12+
});
13+
14+
it('throws for a token larger than 4096 bytes', () => {
15+
expect(() => checkTokenFitsInCookie('a'.repeat(4097))).toThrow(/exceeds the maximum cookie size/);
16+
});
17+
});
18+
19+
describe('loginEntraID', () => {
20+
const returnURL = `${config.frontend.url}/auth/callback`;
21+
22+
let req: { login: jest.Mock };
23+
let res: Partial<Response> & { redirect: jest.Mock; cookie: jest.Mock };
24+
let next: jest.Mock;
25+
26+
// Replace passport.authenticate with a stub that immediately invokes the controller's
27+
// callback with the supplied (err, user, info), mimicking a finished EntraID round-trip.
28+
const stubAuthenticate = (err: Error | null, user: unknown, info?: Record<string, string>) => {
29+
jest
30+
.spyOn(passport, 'authenticate')
31+
.mockImplementation(
32+
((_strategy: unknown, _opts: unknown, cb: (e: Error | null, u: unknown, i?: unknown) => void) => () =>
33+
cb(err, user, info)) as unknown as typeof passport.authenticate
34+
);
35+
};
36+
37+
beforeEach(() => {
38+
req = { login: jest.fn((_user, _opts, cb: (e: Error | null) => void) => cb(null)) };
39+
res = { redirect: jest.fn(), cookie: jest.fn() };
40+
next = jest.fn();
41+
});
42+
43+
afterEach(() => jest.restoreAllMocks());
44+
45+
it('redirects with error=provider when passport returns an error', () => {
46+
stubAuthenticate(new Error('boom'), undefined);
47+
loginEntraID(req as unknown as Request, res as Response, next);
48+
expect(res.redirect).toHaveBeenCalledWith(`${returnURL}?error=provider`);
49+
expect(res.cookie).not.toHaveBeenCalled();
50+
});
51+
52+
it('redirects with error=provider when no user is returned', () => {
53+
stubAuthenticate(null, undefined, { message: 'no user' });
54+
loginEntraID(req as unknown as Request, res as Response, next);
55+
expect(res.redirect).toHaveBeenCalledWith(`${returnURL}?error=provider`);
56+
});
57+
58+
it('redirects with error=login when req.login fails', () => {
59+
stubAuthenticate(null, { id: 'user-1', email: 'a@b.com' });
60+
req.login = jest.fn((_user, _opts, cb: (e: Error | null) => void) => cb(new Error('login failed')));
61+
loginEntraID(req as unknown as Request, res as Response, next);
62+
expect(res.redirect).toHaveBeenCalledWith(`${returnURL}?error=login`);
63+
expect(res.cookie).not.toHaveBeenCalled();
64+
});
65+
66+
it('sets a jwt cookie and redirects to the callback on success', () => {
67+
const user = { id: 'user-1', email: 'a@b.com', name: 'A B', status: 'active', globalRoles: [], groupRoles: [] };
68+
stubAuthenticate(null, user);
69+
70+
loginEntraID(req as unknown as Request, res as Response, next);
71+
72+
expect(res.cookie).toHaveBeenCalledWith('jwt', expect.any(String), expect.objectContaining({ httpOnly: true }));
73+
expect(res.redirect).toHaveBeenCalledWith(returnURL);
74+
75+
const token = res.cookie.mock.calls[0][1] as string;
76+
const decoded = jwt.verify(token, config.auth.jwt.secret) as { user: { id: string } };
77+
expect(decoded.user.id).toBe('user-1');
78+
});
79+
});
80+
});

0 commit comments

Comments
 (0)