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/gap 2420 admin #429

Merged
merged 21 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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
Loading