Skip to content

Commit

Permalink
Feat/gap 2420 admin (#429)
Browse files Browse the repository at this point in the history
* add request/response logging to applicant package

* expand response log message

* amend error logs

* fix tests

* fix middleware tests

* rm logs

* minor additions

* extract ternary

* amend next-logger import

* add request/response logging to admin

* update middleware tests

* minor test refactor

* fix tests

* fix build error

* fix tests

* fix build error
  • Loading branch information
jgunnCO authored Apr 12, 2024
1 parent ffcdb10 commit d34b9b4
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 59 deletions.
2 changes: 1 addition & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"isomorphic-fetch": "^3.0.0",
"sass": "1.62.1",
"typescript": "^4.6.3",
"urlpattern-polyfill": "^9.0.0"
"urlpattern-polyfill": "^10.0.0"
},
"browserslist": {
"production": [
Expand Down
1 change: 1 addition & 0 deletions packages/admin/setupJestMock.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@testing-library/jest-dom';
import 'isomorphic-fetch';
import 'urlpattern-polyfill';

jest.mock('next/head', () => {
return {
Expand Down
98 changes: 75 additions & 23 deletions packages/admin/src/middleware.page.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
// eslint-disable-next-line @next/next/no-server-import-in-page
import { type NextRequest, NextResponse, URLPattern } from 'next/server';
import { NextRequest, NextResponse, URLPattern } from 'next/server';
import { v4 } from 'uuid';
import { getLoginUrl, parseJwt } from './utils/general';
import { isAdminSessionValid } from './services/UserService';
import { csrfMiddleware } from './utils/csrfMiddleware';
import { getLoginUrl, parseJwt } from './utils/general';

// It will apply the middleware to all those paths
// (if new folders at page root are created, they need to be included here)
export const config = {
matcher: [
'/build-application/:path*',
'/dashboard/:path*',
'/new-scheme/:path*',
'/scheme/:path*',
'/scheme-list/:path*',
'/super-admin-dashboard/:path*',
],
};
import { logger } from './utils/logger';
import { HEADERS } from './utils/constants';

export async function middleware(req: NextRequest) {
const authenticateRequest = async (req: NextRequest) => {
const rewriteUrl = req.url;
const res = NextResponse.rewrite(rewriteUrl);
const authCookie = req.cookies.get('session_id');
Expand All @@ -30,50 +20,112 @@ export async function middleware(req: NextRequest) {
if (isSubmissionExportLink(req)) {
url += `?${generateRedirectUrl(req)}`;
}
console.log(`Not authorised - logging in via: ${url}`);
logger.info(`Not authorised - logging in via: ${url}`);
return NextResponse.redirect(url);
}

const isValidSession = await isValidAdminSession(authCookie);
if (!isValidSession) {
const url = getLoginUrl({ redirectToApplicant: true });
console.log(`Admin session invalid - logging in via applicant app: ${url}`);
logger.info(`Admin session invalid - logging in via applicant app: ${url}`);
return NextResponse.redirect(url, {
status: 302,
});
}

if (hasJwtExpired(userJwtCookie)) {
const url = `${getLoginUrl()}?${generateRedirectUrl(req)}`;
console.log(`Jwt expired - logging in via: ${url}`);
logger.info(`Jwt expired - logging in via: ${url}`);
return NextResponse.redirect(url);
}

if (isJwtExpiringSoon(userJwtCookie)) {
const url = `${process.env.REFRESH_URL}?${generateRedirectUrl(req)}`;
console.log(`Refreshing JWT - redircting to: ${url}`);
logger.info(`Refreshing JWT - redircting to: ${url}`);
return NextResponse.redirect(url);
}

if (isAdBuilderRedirectAndDisabled(req)) {
const url = req.nextUrl.clone();
url.pathname = '/404';
console.log(`Ad builder disabled - redirecting to 404: ${url.toString()}`);
logger.info(`Ad builder disabled - redirecting to 404: ${url.toString()}`);
return NextResponse.redirect(url);
}

console.log('User is authorised');
logger.info('User is authorised');
addAdminSessionCookie(res, authCookie);
res.headers.set('Cache-Control', 'no-store');
return res;
};

const httpLoggers = {
req: (req: NextRequest) => {
const correlationId = v4();
req.headers.set(HEADERS.CORRELATION_ID, correlationId);
logger.http('Incoming request', {
...logger.utils.formatRequest(req),
correlationId,
});
},
res: (req: NextRequest, res: NextResponse) =>
logger.http(
'Outgoing response - PLEASE NOTE: this represents a snapshot of the response as it exits the middleware, changes made by other server code (eg getServerSideProps) will not be shown',
{
...logger.utils.formatResponse(res),
correlationId: req.headers.get(HEADERS.CORRELATION_ID),
}
),
};

type LoggerType = keyof typeof httpLoggers;

const urlsToSkip = ['/_next/', '/assets/', '/javascript/'];

const getConditionalLogger = (req: NextRequest, type: LoggerType) => {
const userAgentHeader = req.headers.get('user-agent') || '';
const shouldSkipLogging =
userAgentHeader.startsWith('ELB-HealthChecker') ||
urlsToSkip.some((url) => req.nextUrl.pathname.startsWith(url));
return shouldSkipLogging ? () => undefined : httpLoggers[type];
};

const authenticatedPaths = [
'/build-application/:path*',
'/dashboard/:path*',
'/new-scheme/:path*',
'/scheme/:path*',
'/scheme-list/:path*',
'/super-admin-dashboard/:path*',
].map((pathname) => new URLPattern({ pathname }));

const isAuthenticatedPath = (pathname: string) =>
authenticatedPaths.some((authenticatedPath) =>
authenticatedPath.test({ pathname })
);

export async function middleware(req: NextRequest) {
const logRequest = getConditionalLogger(req, 'req');
const logResponse = getConditionalLogger(req, 'res');
const rewriteUrl = req.url;
let res = NextResponse.rewrite(rewriteUrl);
logRequest(req, res);

if (isAuthenticatedPath(req.nextUrl.pathname)) {
await csrfMiddleware(req, res);
res = await authenticateRequest(req);
}
logResponse(req, res);
return res;
}

function generateRedirectUrl(req: NextRequest) {
const redirectUrl = process.env.HOST + req.nextUrl.pathname;
const redirectUrlSearchParams = encodeURIComponent(
req.nextUrl.searchParams.toString()
);
return `redirectUrl=${redirectUrl}?${redirectUrlSearchParams}`;
return `redirectUrl=${redirectUrl}${
redirectUrlSearchParams ? '?' + redirectUrlSearchParams : ''
}`;
}

function addAdminSessionCookie(res: NextResponse, authCookie: RequestCookie) {
Expand Down
70 changes: 45 additions & 25 deletions packages/admin/src/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import '@testing-library/jest-dom';
import { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
// eslint-disable-next-line @next/next/no-server-import-in-page
import { NextRequest, NextResponse, URLPattern } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { NextURL } from 'next/dist/server/web/next-url';
import { middleware } from './middleware.page';
import { isAdminSessionValid } from './services/UserService';
import { getLoginUrl, parseJwt } from './utils/general';
Expand All @@ -13,36 +14,60 @@ jest.mock('./services/UserService', () => ({
isAdminSessionValid: jest.fn(),
}));

jest.mock('next/server', () => ({
...jest.requireActual('next/server'),
URLPattern: jest.fn(),
}));
let cookieStore = {},
headerStore = {};

const getMockRequest = (url: string) =>
({
cookies: {
get: (key) => cookieStore[key],
getAll: () =>
Object.entries(cookieStore).map(([name, value]) => ({ name, value })),
set: (name, value) => (cookieStore[name] = { name, value }),
clear: () => (cookieStore = {}),
},
headers: {
get: (key) => headerStore[key],
entries: () => [],
set: (key, value) => (headerStore[key] = value),
},
setUrl(url) {
this.nextUrl = new NextURL(url);
},
url,
nextUrl: new NextURL(url),
method: 'GET',
} as unknown as NextRequest & { setUrl: (string) => string });

const mockedGetLoginUrl = jest.mocked(getLoginUrl);
const mockedParseJwt = jest.mocked(parseJwt);
const mockedIsAdminSessionValid = jest.mocked(isAdminSessionValid);
const mockedUrlPattern = jest.mocked(URLPattern);

const loginUrl = 'http://localhost:8082/login';
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // Expiring in 1 hour

describe('middleware', () => {
const req = new NextRequest(
'http://localhost:3000/apply/test/destination?scheme=1&grant=2'
);
let req;

beforeEach(() => {
// cleanup
jest.clearAllMocks();

// setup
req = getMockRequest('http://localhost:3000/dashboard');
process.env.MAX_COOKIE_AGE = '21600';
process.env.ONE_LOGIN_ENABLED = 'false';
process.env.LOGIN_URL = loginUrl;
process.env.V2_LOGIN_URL = loginUrl;
process.env.FEATURE_ADVERT_BUILDER = 'enabled';
process.env.VALIDATE_USER_ROLES_IN_MIDDLEWARE = 'true';

process.env.JWT_COOKIE_NAME = 'user-service-token';
process.env.HOST = 'http://localhost:3003';
process.env.HOST = 'http://localhost:3000';
process.env.REFRESH_URL = 'http://localhost:8082/refresh';

headerStore = {};
req.cookies.clear();
req.cookies.set('session_id', 'session_id_value');
req.cookies.set('user-service-token', 'user-service-value');
Expand All @@ -52,9 +77,6 @@ describe('middleware', () => {
});
mockedIsAdminSessionValid.mockImplementation(async () => true);
mockedGetLoginUrl.mockReturnValue(loginUrl);
mockedUrlPattern.mockReturnValue({
test: jest.fn().mockReturnValue(false),
});
});

it('Redirect to the login page when there is no authCookie', async () => {
Expand All @@ -78,21 +100,19 @@ describe('middleware', () => {
});

it('Should redirect with a redirectUrl when user not authorised AND path is submissionExport', async () => {
mockedUrlPattern.mockReturnValue({
test: jest.fn().mockReturnValue(true),
});
req.setUrl('http://localhost:3000/scheme/1/a1b2');
req.cookies.clear();

const result = await middleware(req);

expect(result).toBeInstanceOf(NextResponse);
expect(result.headers.get('Location')).toBe(
`${loginUrl}?redirectUrl=http://localhost:3003/apply/test/destination?scheme%3D1%26grant%3D2`
`${loginUrl}?redirectUrl=http://localhost:3000/scheme/1/a1b2`
);
});

it('Redirect to the login page when the admin session is invalid', async () => {
mockedIsAdminSessionValid.mockImplementation(async () => false);
mockedIsAdminSessionValid.mockResolvedValue(false);

const result = await middleware(req);

Expand All @@ -112,7 +132,7 @@ describe('middleware', () => {

expect(res.status).toBe(307);
expect(res.headers.get('Location')).toBe(
`${loginUrl}?redirectUrl=http://localhost:3003/apply/test/destination?scheme%3D1%26grant%3D2`
`${loginUrl}?redirectUrl=http://localhost:3000/dashboard`
);
});

Expand All @@ -127,13 +147,13 @@ describe('middleware', () => {

expect(res.status).toBe(307);
expect(res.headers.get('Location')).toBe(
`http://localhost:8082/refresh?redirectUrl=http://localhost:3003/apply/test/destination?scheme%3D1%26grant%3D2`
`http://localhost:8082/refresh?redirectUrl=http://localhost:3000/dashboard`
);
});

describe('Ad builder middleware', () => {
const adBuilderReq = new NextRequest(
'http://localhost:3000/apply/admin/scheme/1/advert/129744d5-0746-403f-8a5f-a8c9558bc4e3/grantDetails/1'
const adBuilderReq = getMockRequest(
'http://localhost:3000/scheme/1/advert/129744d5-0746-403f-8a5f-a8c9558bc4e3/grantDetails/1'
);

beforeEach(() => {
Expand All @@ -149,7 +169,7 @@ describe('middleware', () => {

expect(result).toBeInstanceOf(NextResponse);
expect(result.headers.get('x-middleware-rewrite')).toStrictEqual(
'http://localhost:3000/apply/admin/scheme/1/advert/129744d5-0746-403f-8a5f-a8c9558bc4e3/grantDetails/1'
'http://localhost:3000/scheme/1/advert/129744d5-0746-403f-8a5f-a8c9558bc4e3/grantDetails/1'
);
});

Expand All @@ -159,7 +179,7 @@ describe('middleware', () => {
const result = await middleware(adBuilderReq);

expect(result).toBeInstanceOf(NextResponse);
expect(result.headers.get('location')).toStrictEqual(
expect(result.headers.get('Location')).toStrictEqual(
'http://localhost:3000/404'
);
});
Expand All @@ -179,7 +199,7 @@ describe('middleware', () => {
const result = await middleware(req);

expect(result.headers.get('x-middleware-rewrite')).toStrictEqual(
'http://localhost:3000/apply/test/destination?scheme=1&grant=2'
'http://localhost:3000/dashboard'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,10 @@ const getServerSideProps = async ({
};

case 'save-and-continue' in body:
questionSummary =
((await getSummaryFromSession(
'updatedQuestion',
sessionId
)) as unknown as QuestionWithOptionsSummary) || {};
questionSummary = ((await getSummaryFromSession(
'updatedQuestion',
sessionId
)) || {}) as unknown as QuestionWithOptionsSummary;
await patchQuestion(sessionId, applicationId, sectionId, questionId, {
...questionSummary,
options: options,
Expand Down
5 changes: 4 additions & 1 deletion packages/admin/src/utils/QuestionPageGetServerSideProps.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Redirect } from 'next';
import CallServiceMethod from './callServiceMethod';
import {
ValidationError,
Expand Down Expand Up @@ -50,7 +51,9 @@ export default async function QuestionPageGetServerSideProps<
return postResponse;
}

const { fieldErrors, previousValues } = generateValidationProps(postResponse);
const { fieldErrors, previousValues } = generateValidationProps(
postResponse as Exclude<typeof postResponse, { redirect: Redirect }>
);

return {
props: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ export const ELASTIC_GRANT_PAGE_FIELDS = [
'fields.label',
'fields.grantApplicantType',
];

export const HEADERS = {
CORRELATION_ID: 'x-correlation-id',
} as const;
Loading

0 comments on commit d34b9b4

Please sign in to comment.