Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GEO-1250 - unit tests for token refresh #868

Merged
merged 13 commits into from
Dec 5, 2024
Merged
70 changes: 66 additions & 4 deletions backend/src/v1/routes/admin-auth-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import request from 'supertest';
import express, { Application } from 'express';
import request from 'supertest';
import router from './admin-auth-routes';

const mockAuthenticate = jest.fn();
Expand All @@ -8,12 +8,19 @@ jest.mock('passport', () => ({
jest.fn((req, res, next) => mockAuthenticate(req, res, next)),
}));

const mockIsTokenExpired = jest.fn();
const mockIsRenewable = jest.fn();
const mockRenew = jest.fn();
const mockGenerateFrontendToken = jest.fn();
const mockRenewBackendAndFrontendTokens = jest.fn((req, res) => {
res.status(200).json({});
});
const mockHandleCallBackAzureIdir = jest.fn();
jest.mock('../services/admin-auth-service', () => {
const actualAdminAuth = jest.requireActual(
'../services/admin-auth-service',
) as any;
const mockedAdminAuth = jest.genMockFromModule(
const mockedAdminAuth = jest.createMockFromModule(
'../services/admin-auth-service',
) as any;

Expand All @@ -22,11 +29,30 @@ jest.mock('../services/admin-auth-service', () => {
adminAuth: { ...actualAdminAuth.adminAuth },
};

mocked.adminAuth.handleCallBackAzureIdir = () => mockHandleCallBackAzureIdir()

mocked.adminAuth.handleCallBackAzureIdir = () =>
mockHandleCallBackAzureIdir();
mocked.adminAuth.isTokenExpired = () => mockIsTokenExpired();
mocked.adminAuth.isRenewable = () => mockIsRenewable();
mocked.adminAuth.renew = () => mockRenew();
mocked.adminAuth.generateFrontendToken = () => mockGenerateFrontendToken();
mocked.adminAuth.renewBackendAndFrontendTokens = (req, res) => {
mockRenewBackendAndFrontendTokens(req, res);
};
return mocked;
});

const mockLogout = jest.fn();
const mockRequest = {
logout: jest.fn(mockLogout),
session: {
destroy: jest.fn(),
},
user: {
idToken: 'ey....',
jwtFrontend: '567ghi',
},
};

let app: Application;
describe('admin-auth-routes', () => {
beforeEach(() => {
Expand Down Expand Up @@ -61,4 +87,40 @@ describe('admin-auth-routes', () => {
});
});
});

describe('/refresh', () => {
describe('if the given token (in the request body) is expired and is renewable', () => {
it('should return 200 with current user token', () => {
mockIsTokenExpired.mockReturnValue(false);
mockIsRenewable.mockReturnValue(false);
const mockCorrelationId = 12;
app.use((req: any, res, next) => {
req.session = {
...mockRequest.session,
companyDetails: { id: 1 },
correlationID: mockCorrelationId,
};
req.user = {
...mockRequest.user,
jwt: 'jwt',
refreshToken: 'jwt',
};
req.logout = mockRequest.logout;
req.body = { refreshToken: mockRequest.user.jwtFrontend };
next();
});
app.use('/auth', router);
return request(app)
.post('/auth/refresh')
.set('Accept', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
jwtFrontend: mockRequest.user.jwtFrontend,
correlationID: mockCorrelationId,
});
});
});
});
});
});
2 changes: 1 addition & 1 deletion backend/src/v1/routes/admin-auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ router.post(
res.status(401).json(UnauthorizedRsp);
} else if (adminAuth.isTokenExpired(user.jwt)) {
if (user?.refreshToken && adminAuth.isRenewable(user.refreshToken)) {
return adminAuth.renewBackendAndFrontendTokens(req, res);
return await adminAuth.renewBackendAndFrontendTokens(req, res);
} else {
res.status(401).json(UnauthorizedRsp);
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/v1/routes/public-auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ router.post(
res.status(401).json(UnauthorizedRsp);
} else if (publicAuth.isTokenExpired(user.jwt)) {
if (user?.refreshToken && publicAuth.isRenewable(user.refreshToken)) {
return publicAuth.renewBackendAndFrontendTokens(req, res);
return await publicAuth.renewBackendAndFrontendTokens(req, res);
} else {
res.status(401).json(UnauthorizedRsp);
}
Expand Down
121 changes: 106 additions & 15 deletions backend/src/v1/services/admin-auth-service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
import { faker } from '@faker-js/faker';
import { convert, LocalDateTime, ZoneId } from '@js-joda/core';
import axios from 'axios';
import { KEYCLOAK_IDP_HINT_AZUREIDIR } from '../../constants';
import prisma from '../prisma/prisma-client';
import { adminAuth, IUserDetails } from './admin-auth-service';
import { ROLE_ADMIN_USER } from './sso-service';
import { utils } from './utils-service';

const mockGetSessionUser = jest.fn();
jest.mock('./utils-service', () => ({
utils: {
getSessionUser: () => mockGetSessionUser(),
},
}));
//Mock the entire axios module so we never inadvertently make real
//HTTP calls to remote services
jest.mock('axios');

jest.mock('../../config', () => ({
config: {
get: (key) => {
const settings = {
'oidc:adminClientId': '1234',
};
const mockGetSessionUser = jest.fn();
jest.mock('./utils-service', () => {
const actualUtils = jest.requireActual('./utils-service').utils;
return {
utils: {
...actualUtils,
getSessionUser: () => mockGetSessionUser(),
getOidcDiscovery: jest.fn(),
getKeycloakPublicKey: jest.fn(),
},
};
});

return settings[key];
jest.mock('../../config', () => {
const actualConfig = jest.requireActual('../../config').config;
return {
config: {
get: (key) => {
const settings = {
'oidc:adminClientId': '1234',
'tokenGenerate:issuer': 'issuer',
'server:adminFrontend': 'server-admin-frontend',
'tokenGenerate:audience': 'audience',
'tokenGenerate:privateKey': actualConfig.get(
'tokenGenerate:privateKey',
),
};
return settings[key];
},
},
},
}));
};
});

const actualJsonWebToken = jest.requireActual('jsonwebtoken');

const mockJWTDecode = jest.fn();
jest.mock('jsonwebtoken', () => ({
Expand Down Expand Up @@ -74,6 +96,75 @@ describe('admin-auth-service', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('generateFrontendToken', () => {
it('generates a new JWT token that expires in 30 minute (1800 seconds)', async () => {
const token = adminAuth.generateFrontendToken();
const payload: any = actualJsonWebToken.decode(token);

const nowSeconds = Date.now().valueOf() / 1000;
const ttlSeconds = payload.exp - nowSeconds;

const expectedTtlSeconds = 1800; //30 minutes
const ttlToleranceSeconds = 5;

//Because a small (but non-zero) amount of time elapsed between when
//the token was generated and when its expiration date was checked, we
//must expect the time-to-live (TTL) to be slightly less than 30 minutes.
//Check that the TTL is within a small tolerance of the expected TTL.
expect(ttlSeconds).toBeLessThanOrEqual(expectedTtlSeconds);
expect(ttlSeconds).toBeGreaterThanOrEqual(
expectedTtlSeconds - ttlToleranceSeconds,
);
});
});

describe('renew', () => {
describe('when the identity provider successfully refreshes the tokens', () => {
it('responds with an object containing three new tokens (access, id and refresh)', async () => {
//Mock the call made by auth.renew(...) to utils.getOidcDiscovery(...) so it doesn't
//depend on a remote service. The mocked return value must include a "token_endpoint"
//property, but the value of that property isn't important because
//we're also mocking the HTTP request (see below) that uses the return value
const mockGetOidcDiscoveryResponse = { token_endpoint: null };
(utils.getOidcDiscovery as jest.Mock).mockResolvedValueOnce(
mockGetOidcDiscoveryResponse,
);

//Mock the HTTP post request made by auth.renew(...) to the identity provider to
//refresh the token.
const mockSuccessfulRefreshTokenResponse = {
data: {
access_token: 'new_access_token',
refresh_token: 'new_refresh_token',
id_token: 'new_id_token',
},
};
(axios.post as jest.Mock).mockResolvedValueOnce(
mockSuccessfulRefreshTokenResponse,
);

//We don't need a real refresh token because we're mocking the call to the
//identity provider
const dummyRefreshToken = 'old_refresh_token';

const result = await adminAuth.renew(dummyRefreshToken);

//Confirm that the auth.renew(...) function returns a response object
//with the expected properties
expect(result.jwt).toBe(
mockSuccessfulRefreshTokenResponse.data.access_token,
);
expect(result.refreshToken).toBe(
mockSuccessfulRefreshTokenResponse.data.refresh_token,
);
expect(result.idToken).toBe(
mockSuccessfulRefreshTokenResponse.data.id_token,
);
});
});
});

describe('handleCallBackAzureIdir', () => {
it('should return login error when jwt decode fails', async () => {
mockGetSessionUser.mockReturnValue({});
Expand Down
20 changes: 14 additions & 6 deletions backend/src/v1/services/auth-utils-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const mockRenewSuccessResult = {

class MockAuthSubclass extends AuthBase {
public override async renew(refreshToken: string): Promise<any> {}
public override generateFrontendToken() {}
public override generateFrontendToken(): string {
return '';
}
public override getUserDescription(session: any): string {
return 'Mock user';
}
Expand Down Expand Up @@ -75,18 +77,24 @@ describe('isRenewable', () => {
describe('renewBackendAndFrontendTokens', () => {
describe('when the refresh token is successfully exchanged for new backend tokens', () => {
it('sets a success code in the response', async () => {
const mockFrontendToken = 'sdf345dsf';
jest.spyOn(mockAuth, 'renew').mockResolvedValue(mockRenewSuccessResult);
const generateFrontendTokenSpy = jest
.spyOn(mockAuth, 'generateFrontendToken')
.mockReturnValue(mockFrontendToken);
const req = {
user: { refreshToken: 'mock refresh token' } as unknown,
session: {},
} as Request;
const res = {
status: jest.fn().mockReturnValue({
json: jest.fn(),
}) as unknown,
} as Response;
const res: any = new Object();
res.status = jest.fn().mockReturnValue(res) as unknown;
res.json = jest.fn() as unknown;

await mockAuth.renewBackendAndFrontendTokens(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json.mock.calls[0][0].jwtFrontend).toBe(
generateFrontendTokenSpy.mock.results[0].value,
);
});
});
describe('when the refresh token is not successfully exchanged for new backend tokens', () => {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/v1/services/auth-utils-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ abstract class AuthBase {
return result;
}

protected generateFrontendTokenImpl(audience: string) {
protected generateFrontendTokenImpl(audience: string): string {
const i = config.get('tokenGenerate:issuer');
const s = '[email protected]';
const signOptions: SignOptions = {
Expand Down
17 changes: 17 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"vuetify": "^3.4.7"
},
"devDependencies": {
"@faker-js/faker": "^9.3.0",
"@pinia/testing": "^0.1.3",
"@playwright/test": "^1.43.1",
"@testing-library/jest-dom": "^6.4.2",
Expand Down
Loading
Loading