From 52c069c3039d5aefa86170caa999ac9c5a032032 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:06:10 +0100 Subject: [PATCH 01/27] VIA-87 AS/DB Clean up comments --- src/utils/auth/get-client-config.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/utils/auth/get-client-config.test.ts b/src/utils/auth/get-client-config.test.ts index b29a96ad..a4209645 100644 --- a/src/utils/auth/get-client-config.test.ts +++ b/src/utils/auth/get-client-config.test.ts @@ -52,11 +52,6 @@ describe("getClientConfig", () => { }); it("throws if error thrown by discovery method", async () => { - // TODO: VIA 87 2025-04-16 how to get the mock of client.discovery to throw an error when we cannot reference the module by name? (starred import) - // - // const errorThrownByDiscovery = new Error("error-thrown-by-discovery"); - // set discovery mock to throw that - // expect(await getClientConfig()).rejects.toThrow(errorThrownByDiscovery); const errorThrownByDiscovery = new Error("error-thrown-by-discovery"); mockDiscovery.mockRejectedValue(errorThrownByDiscovery); From 4c70a1497b0263bfb8e7f952a60d5effcad46c7f Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:18:31 +0100 Subject: [PATCH 02/27] TEMPCOMMIT - WIP of /authorize call construction and tests --- .env.local | 1 + src/app/auth/sso/route.test.ts | 110 +++++++++++++++++++++++++--- src/app/auth/sso/route.ts | 29 +++++++- src/utils/auth/get-auth-config.ts | 11 ++- src/utils/auth/get-client-config.ts | 4 +- 5 files changed, 134 insertions(+), 21 deletions(-) diff --git a/.env.local b/.env.local index 887b3ba8..4f62007c 100644 --- a/.env.local +++ b/.env.local @@ -17,3 +17,4 @@ VACCINATION_APP_URL=http://localhost:3000 NHS_LOGIN_URL=https://auth.sandpit.signin.nhs.uk NHS_LOGIN_CLIENT_ID=should-not-be-checked-in NHS_LOGIN_SCOPE='openid profile gp_registration_details' +NHS_LOGIN_PRIVATE_KEY_FILE_PATH= diff --git a/src/app/auth/sso/route.test.ts b/src/app/auth/sso/route.test.ts index 32a3b736..cc9e8c99 100644 --- a/src/app/auth/sso/route.test.ts +++ b/src/app/auth/sso/route.test.ts @@ -2,26 +2,114 @@ * @jest-environment node */ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { GET } from "@src/app/auth/sso/route"; -import { configProvider } from "@src/utils/config"; +import { getAuthConfig } from "@src/utils/auth/get-auth-config"; +import { getClientConfig } from "@src/utils/auth/get-client-config"; +import * as client from "openid-client"; -jest.mock("@src/utils/config"); +jest.mock("@src/utils/auth/get-auth-config"); +jest.mock("@src/utils/auth/get-client-config"); +jest.mock("openid-client", () => { + const actualOpenidClient = jest.requireActual("openid-client"); + return { + buildAuthorizationUrl: actualOpenidClient.buildAuthorizationUrl, + discovery: jest.fn(), + }; +}); + +jest.mock("next/server", () => { + const actualNextServer = jest.requireActual("next/server"); + return { + ...actualNextServer, + NextResponse: { + redirect: jest.fn(), + }, + }; +}); const mockNhsLoginUrl = "nhs-login/url"; -const mockNhsLoginScope = "openid profile"; const mockNhsLoginClientId = "vita-client-id"; -const mockVaccinationAppUrl = "vita-base-url"; +const mockNhsLoginScope = "openid profile"; +const mockRedirectUrl = "https://redirect/url"; + +const mockAuthConfig = { + url: mockNhsLoginUrl, + client_id: mockNhsLoginClientId, + scope: mockNhsLoginScope, + redirect_uri: mockRedirectUrl, +}; + +const mockClientConfig = {} as jest.Mock; -(configProvider as jest.Mock).mockImplementation(() => ({ - NHS_LOGIN_URL: mockNhsLoginUrl, - NHS_LOGIN_CLIENT_ID: mockNhsLoginClientId, - NHS_LOGIN_SCOPE: mockNhsLoginScope, - VACCINATION_APP_URL: mockVaccinationAppUrl, -})); +(getAuthConfig as jest.Mock).mockResolvedValue(mockAuthConfig); +(getClientConfig as jest.Mock).mockResolvedValue(mockClientConfig); describe("SSO route", () => { + // let mockBuildAuthorizationUrl: jest.Mock; + let nextResponseRedirect: jest.Mock; + // let mockDiscovery: jest.Mock; + + beforeEach(() => { + // mockBuildAuthorizationUrl = client.buildAuthorizationUrl as jest.Mock; + nextResponseRedirect = NextResponse.redirect as jest.Mock; + // mockDiscovery = client.discovery as jest.Mock; + }); + describe("GET endpoint", () => { + it("passes assertedLoginIdentity JWT on to redirect url", async () => { + const mockAssertedLoginJWT = "asserted-login-jwt-value"; + const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); + inboundUrlWithAssertedParam.searchParams.append( + "assertedLoginIdentity", + mockAssertedLoginJWT, + ); + // const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); + // mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + const request = new NextRequest(inboundUrlWithAssertedParam); + + await GET(request); + + expect(nextResponseRedirect).toHaveBeenCalledTimes(1); + const redirectedUrl = nextResponseRedirect.mock.lastCall[0]; + const searchParams = redirectedUrl.searchParams; + expect(searchParams.get("asserted_login_identity")).toEqual( + mockAssertedLoginJWT, + ); + }); + + it("redirects the user to NHS Login with expected query params", async () => { + const mockAssertedLoginJWT = "asserted-login-jwt-value"; + const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); + inboundUrlWithAssertedParam.searchParams.append( + "assertedLoginIdentity", + mockAssertedLoginJWT, + ); + const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); + // mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + const request = new NextRequest(inboundUrlWithAssertedParam); + + await GET(request); + + expect(nextResponseRedirect).toHaveBeenCalledTimes(1); + const redirectedUrl = nextResponseRedirect.mock.lastCall[0]; + expect(redirectedUrl.redirected).toBe(true); + expect(redirectedUrl.origin).toBe(mockAuthConfig.url); + expect(redirectedUrl.pathname).toBe("/authorize"); + const searchParams = redirectedUrl.searchParams; + expect(searchParams.get("assertedLoginIdentity")).toEqual( + mockAssertedLoginJWT, + ); + expect(searchParams.get("scope")).toEqual("openid%20profile"); + expect(searchParams.get("client_id")).toEqual(mockAuthConfig.client_id); + expect(searchParams.get("redirect_uri")).toEqual( + `${mockAuthConfig.url}/auth/callback`, + ); + expect(searchParams.get("state")).toBeDefined(); + expect(searchParams.get("nonce")).toBeDefined(); + expect(searchParams.get("prompt")).toEqual("none"); + }); + it("should fail if assertedLoginIdentity query parameter not provided", async () => { const urlWithoutAssertedParam = new URL("https://testurl"); diff --git a/src/app/auth/sso/route.ts b/src/app/auth/sso/route.ts index ec9562e4..62ef5209 100644 --- a/src/app/auth/sso/route.ts +++ b/src/app/auth/sso/route.ts @@ -1,14 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import { logger } from "@src/utils/logger"; +import { getClientConfig } from "@src/utils/auth/get-client-config"; +import { getAuthConfig } from "@src/utils/auth/get-auth-config"; +import * as client from "openid-client"; const log = logger.child({ module: "sso-route" }); export async function GET(request: NextRequest) { - if (request.nextUrl.searchParams.get("assertedLoginIdentity")) { - return NextResponse.json({ placeholder: "to-be-implemented" }); - } else { + if (!request.nextUrl.searchParams.get("assertedLoginIdentity")) { log.error("SSO route called without assertedLoginIdentity parameter"); - return NextResponse.json({ message: "Bad request" }, { status: 400 }); } + try { + const state = "not-yet-implemented"; + const authConfig = await getAuthConfig(); + const clientConfig = await getClientConfig(); + const parameters: Record = { + redirect_uri: authConfig.redirect_uri, + scope: authConfig.scope, + state: state, + }; + const redirectTo = client.buildAuthorizationUrl(clientConfig, parameters); + // TODO: add in extra params needed eg prompt + redirectTo.searchParams.append( + "asserted_login_identity", + request.nextUrl.searchParams.get("assertedLoginIdentity"), + ); + + return NextResponse.redirect(redirectTo); + } catch (e) { + log.error("SSO route: error handling not-yet-implemented"); + throw new Error("not-yet-implemented"); + } } diff --git a/src/utils/auth/get-auth-config.ts b/src/utils/auth/get-auth-config.ts index 3c5b801d..a6329ed7 100644 --- a/src/utils/auth/get-auth-config.ts +++ b/src/utils/auth/get-auth-config.ts @@ -17,15 +17,18 @@ const getAuthConfig = async (): Promise => { config = await configProvider(); } + // TODO: VIA-87 2025-04-17 Check how to get URL of deployed lambda and replace + const vitaUrl = process.env.VACCINATION_APP_URL || "not-yet-implemented"; + return { - url: config.VACCINATION_APP_URL, - audience: config.VACCINATION_APP_URL, + url: vitaUrl, + audience: vitaUrl, client_id: config.NHS_LOGIN_CLIENT_ID, scope: config.NHS_LOGIN_SCOPE, - redirect_uri: `${config.VACCINATION_APP_URL}/auth/callback`, + redirect_uri: `${vitaUrl}/auth/callback`, response_type: "code", grant_type: "authorization_code", - post_login_route: `${config.VACCINATION_APP_URL}`, + post_login_route: `${vitaUrl}`, }; }; diff --git a/src/utils/auth/get-client-config.ts b/src/utils/auth/get-client-config.ts index 17a6c802..1d5f6b59 100644 --- a/src/utils/auth/get-client-config.ts +++ b/src/utils/auth/get-client-config.ts @@ -10,8 +10,8 @@ const getClientConfig = async () => { const authConfig = await getAuthConfig(); try { return await client.discovery( - new URL(authConfig.url!), - authConfig.client_id!, + new URL(authConfig.url), + authConfig.client_id, "", client.PrivateKeyJwt(key), ); From d6ef1a1522d9579d4456dfd1fa3a2cff893c5baa Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:27:16 +0100 Subject: [PATCH 03/27] TEMPCOMMIT: tests running with buildAuthorization mocked out todo: mocking this method out prevents testing the response query params --- src/app/auth/sso/route.test.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/app/auth/sso/route.test.ts b/src/app/auth/sso/route.test.ts index cc9e8c99..b6f48924 100644 --- a/src/app/auth/sso/route.test.ts +++ b/src/app/auth/sso/route.test.ts @@ -11,10 +11,8 @@ import * as client from "openid-client"; jest.mock("@src/utils/auth/get-auth-config"); jest.mock("@src/utils/auth/get-client-config"); jest.mock("openid-client", () => { - const actualOpenidClient = jest.requireActual("openid-client"); return { - buildAuthorizationUrl: actualOpenidClient.buildAuthorizationUrl, - discovery: jest.fn(), + buildAuthorizationUrl: jest.fn(), }; }); @@ -23,6 +21,7 @@ jest.mock("next/server", () => { return { ...actualNextServer, NextResponse: { + json: actualNextServer.NextResponse.json, redirect: jest.fn(), }, }; @@ -42,18 +41,16 @@ const mockAuthConfig = { const mockClientConfig = {} as jest.Mock; -(getAuthConfig as jest.Mock).mockResolvedValue(mockAuthConfig); -(getClientConfig as jest.Mock).mockResolvedValue(mockClientConfig); +(getAuthConfig as jest.Mock).mockImplementation(() => mockAuthConfig); +(getClientConfig as jest.Mock).mockImplementation(() => mockClientConfig); describe("SSO route", () => { - // let mockBuildAuthorizationUrl: jest.Mock; + let mockBuildAuthorizationUrl: jest.Mock; let nextResponseRedirect: jest.Mock; - // let mockDiscovery: jest.Mock; beforeEach(() => { - // mockBuildAuthorizationUrl = client.buildAuthorizationUrl as jest.Mock; + mockBuildAuthorizationUrl = client.buildAuthorizationUrl as jest.Mock; nextResponseRedirect = NextResponse.redirect as jest.Mock; - // mockDiscovery = client.discovery as jest.Mock; }); describe("GET endpoint", () => { @@ -64,8 +61,8 @@ describe("SSO route", () => { "assertedLoginIdentity", mockAssertedLoginJWT, ); - // const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); - // mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); + mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); const request = new NextRequest(inboundUrlWithAssertedParam); await GET(request); @@ -86,7 +83,7 @@ describe("SSO route", () => { mockAssertedLoginJWT, ); const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); - // mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); const request = new NextRequest(inboundUrlWithAssertedParam); await GET(request); From d86cd92396b4df55dec9c822b2858a27982f2c81 Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:07:52 +0100 Subject: [PATCH 04/27] VIA-138 AJ Adding the post deployment automatic trigger to warm up the lambda --- infrastructure/environments/dev/main.tf | 9 ++++++ infrastructure/modules/deploy_app/main.tf | 2 +- .../modules/post_deploy/event_bridge.tf | 30 +++++++++++++++++++ infrastructure/modules/post_deploy/locals.tf | 13 ++++++++ .../modules/post_deploy/variables.tf | 9 ++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 infrastructure/modules/post_deploy/event_bridge.tf create mode 100644 infrastructure/modules/post_deploy/locals.tf create mode 100644 infrastructure/modules/post_deploy/variables.tf diff --git a/infrastructure/environments/dev/main.tf b/infrastructure/environments/dev/main.tf index 47efd5aa..5d056a28 100644 --- a/infrastructure/environments/dev/main.tf +++ b/infrastructure/environments/dev/main.tf @@ -11,3 +11,12 @@ module "deploy" { log_retention_in_days = local.log_retention_in_days pino_log_level = local.pino_log_level } + +module "post_deploy" { + source = "../../modules/post_deploy" + + default_tags = local.default_tags + prefix = local.prefix + + depends_on = [module.deploy] +} diff --git a/infrastructure/modules/deploy_app/main.tf b/infrastructure/modules/deploy_app/main.tf index 9d74c070..66916431 100644 --- a/infrastructure/modules/deploy_app/main.tf +++ b/infrastructure/modules/deploy_app/main.tf @@ -29,7 +29,7 @@ module "deploy_app" { additional_iam_policies = [aws_iam_policy.cache_lambda_additional_policy] runtime = var.nodejs_version concurrency = 1 - schedule = "rate(2 days)" + schedule = "rate(7 days)" additional_environment_variables = { SSM_PREFIX = var.ssm_prefix CONTENT_CACHE_PATH = var.content_cache_path diff --git a/infrastructure/modules/post_deploy/event_bridge.tf b/infrastructure/modules/post_deploy/event_bridge.tf new file mode 100644 index 00000000..6d4a6077 --- /dev/null +++ b/infrastructure/modules/post_deploy/event_bridge.tf @@ -0,0 +1,30 @@ +resource "aws_cloudwatch_event_rule" "warmer_lambda_deployment_event_rule" { + name = "${var.prefix}-post-deployment-event-rule" + description = "Triggers Warmer Lambda on deployment" + tags = var.default_tags + event_pattern = jsonencode({ + "source" : ["aws.lambda"], + "detail-type" : ["AWS API Call via CloudTrail"], + "detail" : { + "eventSource" : ["lambda.amazonaws.com"], + "eventName" : ["CreateFunction20150331", "UpdateFunctionCode20150331v2", "UpdateFunctionConfiguration20150331v2"], + "requestParameters" : { + "functionName" : [local.warmer_function_name] + } + } + }) +} + +resource "aws_cloudwatch_event_target" "lambda_target" { + target_id = "lambda" + rule = aws_cloudwatch_event_rule.warmer_lambda_deployment_event_rule.name + arn = local.warmer_function_arn +} + +resource "aws_lambda_permission" "allow_event_bridge_to_invoke_lambda" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = local.warmer_function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.warmer_lambda_deployment_event_rule.arn +} diff --git a/infrastructure/modules/post_deploy/locals.tf b/infrastructure/modules/post_deploy/locals.tf new file mode 100644 index 00000000..91026e49 --- /dev/null +++ b/infrastructure/modules/post_deploy/locals.tf @@ -0,0 +1,13 @@ +data "aws_lambda_functions" "all_lambda_functions" {} + +locals { + warmer_function_name = one([ + for name in data.aws_lambda_functions.all_lambda_functions.function_names : + name if length(regexall("^${var.prefix}.*warmer-function$", name)) == 1 + ]) + + warmer_function_arn = one([ + for arn in data.aws_lambda_functions.all_lambda_functions.function_arns : + arn if length(regexall("^${var.prefix}.*warmer-function$", split(":", arn)[6])) == 1 + ]) +} diff --git a/infrastructure/modules/post_deploy/variables.tf b/infrastructure/modules/post_deploy/variables.tf new file mode 100644 index 00000000..20181829 --- /dev/null +++ b/infrastructure/modules/post_deploy/variables.tf @@ -0,0 +1,9 @@ +variable "prefix" { + type = string + description = "Prefix to be applied to resources created" +} + +variable "default_tags" { + type = map(string) + description = "Map of default key-value pair of tags to add to resources" +} From c70274984d22735f2d0fe3efabedd8bec335b9fd Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:59:35 +0100 Subject: [PATCH 05/27] VIA-138 AJ GitHub iam permissions for the warmer --- infrastructure/github-iam-role-policy.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/infrastructure/github-iam-role-policy.json b/infrastructure/github-iam-role-policy.json index befea0ce..640c1027 100644 --- a/infrastructure/github-iam-role-policy.json +++ b/infrastructure/github-iam-role-policy.json @@ -28,15 +28,28 @@ "dynamodb:DescribeTimeToLive", "dynamodb:ListTagsOfResource", "dynamodb:TagResource", + "events:DeleteRule", + "events:DescribeRule", + "events:ListTagsForResource", + "events:ListTargetsByRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + "events:TagResource", "iam:AttachRolePolicy", "iam:CreatePolicy", "iam:CreateRole", + "iam:DeletePolicy", + "iam:DeleteRole", "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", "iam:GetPolicy", "iam:GetPolicyVersion", "iam:GetRole", "iam:GetRolePolicy", "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicyVersions", "iam:ListRolePolicies", "iam:PassRole", "iam:PutRolePolicy", @@ -47,6 +60,7 @@ "lambda:CreateEventSourceMapping", "lambda:CreateFunction", "lambda:CreateFunctionUrlConfig", + "lambda:DeleteFunction", "lambda:GetAlias", "lambda:GetEventSourceMapping", "lambda:GetFunction", @@ -54,6 +68,7 @@ "lambda:GetFunctionConfiguration", "lambda:GetFunctionUrlConfig", "lambda:GetPolicy", + "lambda:ListFunctions", "lambda:ListTags", "lambda:ListVersionsByFunction", "lambda:PublishVersion", From 23779a51fe14f5a9cca9701e8ce716ce722eca1a Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:28:02 +0100 Subject: [PATCH 06/27] VIA-138 AJ Added the required import needed by the warmer lambda, which was found missing in node environment --- src/services/content-api/parsers/content-styling-service.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/content-api/parsers/content-styling-service.tsx b/src/services/content-api/parsers/content-styling-service.tsx index d193436e..d0a4995f 100644 --- a/src/services/content-api/parsers/content-styling-service.tsx +++ b/src/services/content-api/parsers/content-styling-service.tsx @@ -1,12 +1,13 @@ import sanitiseHtml from "@src/utils/sanitise-html"; import InsetText from "@src/app/_components/nhs-frontend/InsetText"; import NonUrgentCareCard from "@src/app/_components/nhs-frontend/NonUrgentCareCard"; +import React from "react"; import { JSX } from "react"; import { VaccineTypes } from "@src/models/vaccine"; import { VaccinePageContent, VaccinePageSection, - VaccinePageSubsection + VaccinePageSubsection, } from "@src/services/content-api/parsers/content-filter-service"; enum SubsectionTypes { @@ -87,7 +88,7 @@ const extractHeadingAndContent = (text: string): NonUrgentContent => { const getStyledContentForVaccine = async ( vaccine: VaccineTypes, - filteredContent: VaccinePageContent + filteredContent: VaccinePageContent, ): Promise => { const overview: string = filteredContent.overview; const whatVaccineIsFor: StyledPageSection = styleSection( From 8d91eb19b1259111e27d4b003826ce9d9e252cfd Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:30:36 +0100 Subject: [PATCH 07/27] VIA-138 AJ Modified the lambda build to include the file that is an external dependency for some reason --- esbuild.config.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 460d7ec9..1bdb308c 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -10,13 +10,25 @@ const removeDistDirectory = async () => { }); }; +const copyExtraFiles = async () => { + // there is an issue with esbuild putting a warning for xhr-sync-worker.js file being external + // at runtime, lambda complains that the file is not present + // DOMPurify apparently depends on this file + fs.copyFile("./node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js", `${OUTPUT_DIR}/xhr-sync-worker.js`, (err) => { + if (err) throw err; + console.log("Copied xhr-sync-worker.js (workaround)"); + }); +}; + const buildLambda = async () => { await esbuild.build({ entryPoints: ["src/_lambda/content-cache-hydrator/handler.ts"], bundle: true, minify: true, platform: "node", + jsx: "automatic", target: "node22", + external: ["./xhr-sync-worker.js"], outfile: `${OUTPUT_DIR}/lambda.js` }); }; @@ -24,6 +36,7 @@ const buildLambda = async () => { try { await removeDistDirectory(); await buildLambda(); + await copyExtraFiles(); console.log(`Built lambda successfully -> ${OUTPUT_DIR}/lambda.js`); } catch (e) { console.error("Building lambda failed: ", e); From c3b8212adb2fba1d6165dd46dc3dad6d7508340b Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:54:46 +0100 Subject: [PATCH 08/27] VIA-138 AJ Added GitHub permission --- infrastructure/github-iam-role-policy.json | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/github-iam-role-policy.json b/infrastructure/github-iam-role-policy.json index 640c1027..066ddde3 100644 --- a/infrastructure/github-iam-role-policy.json +++ b/infrastructure/github-iam-role-policy.json @@ -72,6 +72,7 @@ "lambda:ListTags", "lambda:ListVersionsByFunction", "lambda:PublishVersion", + "lambda:RemovePermission", "lambda:TagResource", "lambda:UpdateAlias", "lambda:UpdateFunctionCode", From fa55e65a99ac160d5654853bd7381346cba62e1c Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:58:09 +0100 Subject: [PATCH 09/27] VIA-138 AJ Added RUNBOOK.md for manual content cache refresh --- docs/RUNBOOK.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/RUNBOOK.md diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..ccba566b --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,22 @@ +# Runbook for solving production issues + +## Manual vaccine content cache refresh + +### Triggers +- When the periodic cache refresh has failed for some reason +- When nhs.uk tells us that the content needs to be refreshed asap + +### Action +1. Login to the AWS environment and ensure you are in London **eu-west-2** region. +2. Navigate to **Lambda** service. +3. Click on the lambda with prefix `gh-main-vita` and suffix `nextjs-warmer-function` +4. Select **Test** tab to create a new test event +5. Give the new event a meaningful name +6. Keep the event sharing settings to **Private** +7. Use the following template as the event JSON (feel free to add other non-PII fields as necessary for audit) +```json +{ + "who": "", + "why": "" +} +``` From e1d2e8695ea6ccccd4fb6dbe8089f8bc65065c58 Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:25:00 +0100 Subject: [PATCH 10/27] VIA-138 AJ Added ADR-004_Content_caching_architecture.md --- .../ADR-004_Content_caching_architecture.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/adr/ADR-004_Content_caching_architecture.md diff --git a/docs/adr/ADR-004_Content_caching_architecture.md b/docs/adr/ADR-004_Content_caching_architecture.md new file mode 100644 index 00000000..35a534b0 --- /dev/null +++ b/docs/adr/ADR-004_Content_caching_architecture.md @@ -0,0 +1,97 @@ +# ADR-004: Content caching architecture + +>| | | +>| ------------ |--------------------------------------------------------| +>| Date | `22/04/2025` | +>| Status | `Accepted` | +>| Deciders | `Engineering, Architecture, ` | +>| Significance | `Structure, Nonfunctional characteristics, Interfaces` | +>| Owners | Ankur Jain, Elena Oanea | + +--- + +- [ADR-004: Content caching architecture](#adr-004-content-caching-architecture) + - [Context](#context) + - [Decision](#decision) + - [Assumptions](#assumptions) + - [Drivers](#drivers) + - [Options](#options) + - [Outcome](#outcome) + - [Rationale](#rationale) + - [Consequences](#consequences) + - [Compliance](#compliance) + - [Notes](#notes) + - [Actions](#actions) + - [Tags](#tags) + +## Context +According to the NHS.UK website content API guidelines for caching, we require a minimum of 7 days before calling the +endpoint again to fetch updated content. This requires us to keep a cache in place. There are two problems to design for. +Firstly, how do we keep the cache updated - meaning cache inserts, updates and deletes. Secondly, how and when does the +app read/write from/to cache that is the latest content. + +## Decision +We decided to keep the design simple for maintainability purposes by separating the reading and writing to two separate +and independent processes. The writing part ensures to keep the cache updated as per the caching guidelines. The reading +part just reads from the cache assuming it is the correct and most up-to-date. This architecture is optimised for write +few and read many operations, which is our use case. + +To ensure that the cache has the most up-to-date data for all vaccines, we have three mechanisms: - +1. a post deployment trigger that updates the cache each time a change is made to the writer or vaccines list. +2. a periodic cron job that updates the cache at a predefined frequency. +3. on demand trigger that allows updating when requested by nhs.uk. + +```mermaid +--- +title: Content cache architecture +--- +graph TD; + A(Show vaccine content - client side); + B(AWS Lambda - nextjs server action); + C[(AWS S3 Cache)]; + D(Content cache hydrator); + E(Cron); + A -- triggers --> B; + B -- reads --> C; + E -- triggers --> D; + D -- writes --> C; +``` + +### Assumptions +- The data itself is not updated very frequently because if it did, then the reads could suffer latency due to locking for updates. +- When we roll out the VitA app for the first time, there is a flag in NHS app that needs to be turned on for us to go live. + This gives sufficient time for the cache writer to have pulled in all vaccine content. +- When we roll out new vaccines, there is a slight delay (seconds) between deployment finish and cache writer trigger. + During this time, if a user visits the new vaccine page, they will see an error page. This should go away as soon as + the new content is available. + +### Drivers +Mostly simplifying the read process, so that it does not get mixed with writing logic. + +### Options +Alternative design was to fetch the content on a cache miss and update the cache. After which, the subsequent reads +would succeed. This makes the read complex, and we really wanted to make read simple as that is going to be frequent. + +### Outcome +The decision is reversible, if it turns out that people are seeing error pages frequently. This would be monitored. + +### Rationale +Design that optimises for multiple reads and very infrequent writes. +Design that is simple to debug and separating the concerns makes it easier. + +## Consequences +As outlined above in assumptions, there might be intermittent error pages shown when the vaccine content is being pulled. + +## Compliance +The errors would be monitored so that service level objectives are met. + +## Notes +None + +## Actions + +- [x] Ankur Jain, 22/04/2025, created the ADR + +## Tags + +`#performance, #maintainability, #testability, #deployability, #modularity, #simplicity` From 20af9425828dc7d28f5b840ed0c9f17850641e40 Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:18:30 +0100 Subject: [PATCH 11/27] VIA-138 AJ/MD Adding types --- src/services/content-api/gateway/content-reader-service.ts | 3 ++- src/utils/get-ssm-param.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/services/content-api/gateway/content-reader-service.ts b/src/services/content-api/gateway/content-reader-service.ts index aeacd325..37e7a0a3 100644 --- a/src/services/content-api/gateway/content-reader-service.ts +++ b/src/services/content-api/gateway/content-reader-service.ts @@ -20,8 +20,9 @@ import { logger } from "@src/utils/logger"; import { isS3Path, S3_PREFIX } from "@src/utils/path"; import { readFile } from "node:fs/promises"; import { Readable } from "stream"; +import { Logger } from "pino"; -const log = logger.child({ module: "content-reader-service" }); +const log: Logger = logger.child({ module: "content-reader-service" }); const _readFileS3 = async (bucket: string, key: string): Promise => { try { diff --git a/src/utils/get-ssm-param.ts b/src/utils/get-ssm-param.ts index 976ad1a9..c0397554 100644 --- a/src/utils/get-ssm-param.ts +++ b/src/utils/get-ssm-param.ts @@ -2,16 +2,17 @@ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; import type { GetParameterCommandOutput } from "@aws-sdk/client-ssm"; import { AWS_PRIMARY_REGION } from "@src/utils/constants"; import { logger } from "@src/utils/logger"; +import { Logger } from "pino"; -const log = logger.child({ module: "get-ssm-param" }); +const log: Logger = logger.child({ module: "get-ssm-param" }); const getSSMParam = async (name: string): Promise => { try { - const client = new SSMClient({ + const client: SSMClient = new SSMClient({ region: AWS_PRIMARY_REGION, }); - const command = new GetParameterCommand({ + const command: GetParameterCommand = new GetParameterCommand({ Name: name, WithDecryption: true, }); From fad51b5301eb6f1d10210dace0b65e11342143ac Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:26:13 +0100 Subject: [PATCH 12/27] VIA-138 AJ/MD Added more tests and covered error scenarios in content filter service --- .../parsers/content-filter-service.test.ts | 157 +++++++++++++++++- .../parsers/content-filter-service.ts | 63 +++---- 2 files changed, 189 insertions(+), 31 deletions(-) diff --git a/src/services/content-api/parsers/content-filter-service.test.ts b/src/services/content-api/parsers/content-filter-service.test.ts index aeacd95f..e5f35b9a 100644 --- a/src/services/content-api/parsers/content-filter-service.test.ts +++ b/src/services/content-api/parsers/content-filter-service.test.ts @@ -1,9 +1,162 @@ -import { getFilteredContentForVaccine } from "@src/services/content-api/parsers/content-filter-service"; +import { + getFilteredContentForVaccine, + _extractDescriptionForVaccine, + _extractHeadlineForAspect, + _extractPartsForAspect, + VaccinePageSubsection, + ContentApiVaccineResponse, + _findAspect, + MainEntityOfPage, +} from "@src/services/content-api/parsers/content-filter-service"; import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; import { VaccineTypes } from "@src/models/vaccine"; describe("Content Filter", () => { - describe("getPageCopyForVaccine", () => { + describe("_extractDescriptionForVaccine", () => { + it("should return text from mainEntityOfPage object", async () => { + const expectedOverview: string = + "Generic Vaccine Lead Paragraph (overview)"; + + const overview = _extractDescriptionForVaccine( + genericVaccineContentAPIResponse, + "lead paragraph", + ); + + expect(overview).toEqual(expectedOverview); + }); + + it("should throw error when mainEntity text is undefined", async () => { + const responseWithoutEntityOfPage = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + text: undefined, + }, + ], + }; + + const errorMessage = () => + _extractDescriptionForVaccine( + responseWithoutEntityOfPage, + "lead paragraph", + ); + + expect(errorMessage).toThrow( + "Missing text for description: lead paragraph", + ); + }); + }); + + describe("_extractHeadlineForAspect", () => { + it("should extract headline from aspect", async () => { + const expectedHeadline: string = "Getting Access Health Aspect headline"; + + const headline: string = _extractHeadlineForAspect( + genericVaccineContentAPIResponse, + "GettingAccessHealthAspect", + ); + + expect(headline).toEqual(expectedHeadline); + }); + + it("should throw error when headline is undefined", async () => { + const responseWithoutHeadline = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + headline: undefined, + }, + ], + }; + + const errorMessage = () => + _extractHeadlineForAspect( + responseWithoutHeadline, + "BenefitsHealthAspect", + ); + + expect(errorMessage).toThrow( + "Missing headline for Aspect: BenefitsHealthAspect", + ); + }); + }); + + describe("_extractPartsForAspect", () => { + it("should extract parts from aspect", async () => { + const expectedParts: VaccinePageSubsection[] = [ + { + headline: "", + name: "markdown", + text: "

Benefits Health Aspect paragraph 1

", + }, + ]; + + const parts: VaccinePageSubsection[] = _extractPartsForAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + + expect(parts).toEqual(expectedParts); + }); + + it("should throw an error when subsections are undefined", async () => { + const aspect = "BenefitsHealthAspect"; + const responseWithoutParts: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + }, + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + hasPart: undefined, + }, + ], + }; + + const errorMessage = () => { + _extractPartsForAspect(responseWithoutParts, aspect); + }; + + expect(errorMessage).toThrow(`Missing subsections for Aspect: ${aspect}`); + }); + }); + + describe("_findAspect", () => { + it("should find an aspect", () => { + const expectedAspect: MainEntityOfPage = { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + }; + const aspect: MainEntityOfPage = _findAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + + expect(aspect).toEqual(expectedAspect); + }); + + it("should throw an error when subsections are undefined", async () => { + const aspect = "BenefitsHealthAspect"; + const responseWithoutAboveAspect: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + }, + ], + }; + + const errorMessage = () => { + _findAspect(responseWithoutAboveAspect, aspect); + }; + + expect(errorMessage).toThrow(`Aspect ${aspect} is not present`); + }); + }); + + describe("getFilteredContentForVaccine", () => { it("should return overview text from lead paragraph mainEntityOfPage object", async () => { const expectedOverview = { overview: "Generic Vaccine Lead Paragraph (overview)", diff --git a/src/services/content-api/parsers/content-filter-service.ts b/src/services/content-api/parsers/content-filter-service.ts index f4531ae4..e58a61e4 100644 --- a/src/services/content-api/parsers/content-filter-service.ts +++ b/src/services/content-api/parsers/content-filter-service.ts @@ -16,7 +16,7 @@ type HasPartSubsection = { identifier: string; }; -type MainEntityOfPage = { +export type MainEntityOfPage = { "@type": string; hasHealthAspect?: string; position: number; @@ -70,32 +70,35 @@ export type VaccinePageContent = { webpageLink: string; }; -const findAspect = ( +const _findAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, -) => { - const aspect = response.mainEntityOfPage.find((page: MainEntityOfPage) => - page.hasHealthAspect?.endsWith(aspectName), +): MainEntityOfPage => { + const aspect: MainEntityOfPage | undefined = response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.hasHealthAspect?.endsWith(aspectName), ); - return aspect!; + if (!aspect) { + throw new Error(`Aspect ${aspectName} is not present`); + } + return aspect; }; -const extractHeadlineForAspect = ( +const _extractHeadlineForAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, ): string => { - const aspect: MainEntityOfPage = findAspect(response, aspectName); - if (!aspect.headline) { + const aspect: MainEntityOfPage = _findAspect(response, aspectName); + if (!aspect?.headline) { throw new Error(`Missing headline for Aspect: ${aspectName}`); } return aspect.headline; }; -const extractPartsForAspect = ( +const _extractPartsForAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, ): VaccinePageSubsection[] => { - const aspect: MainEntityOfPage = findAspect(response, aspectName); + const aspect: MainEntityOfPage = _findAspect(response, aspectName); const subsections: VaccinePageSubsection[] | undefined = aspect.hasPart?.map( (part: HasPartSubsection) => { // TODO: fix the schema so that we handle part.headline being undefined @@ -115,20 +118,21 @@ const extractPartsForAspect = ( return subsections; }; -const extractDescriptionForVaccine = ( +const _extractDescriptionForVaccine = ( response: ContentApiVaccineResponse, name: string, ): string => { - const mainEntity = response.mainEntityOfPage.find( - (page: MainEntityOfPage) => page.name === name, - ); - if (!mainEntity || !mainEntity.text) { + const mainEntity: MainEntityOfPage | undefined = + response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.name === name, + ); + if (!mainEntity?.text) { throw new Error(`Missing text for description: ${name}`); } return mainEntity.text; }; -const generateWhoVaccineIsForHeading = (vaccineType: VaccineTypes): string => { +const _generateWhoVaccineIsForHeading = (vaccineType: VaccineTypes): string => { return `Who should have the ${VaccineDisplayNames[vaccineType]} vaccine`; }; @@ -137,27 +141,27 @@ const getFilteredContentForVaccine = async ( apiContent: string, ): Promise => { const content: ContentApiVaccineResponse = JSON.parse(apiContent); - const overview: string = extractDescriptionForVaccine( + const overview: string = _extractDescriptionForVaccine( content, "lead paragraph", ); const whatVaccineIsFor: VaccinePageSection = { - headline: extractHeadlineForAspect(content, "BenefitsHealthAspect"), - subsections: extractPartsForAspect(content, "BenefitsHealthAspect"), + headline: _extractHeadlineForAspect(content, "BenefitsHealthAspect"), + subsections: _extractPartsForAspect(content, "BenefitsHealthAspect"), }; const whoVaccineIsFor: VaccinePageSection = { - headline: generateWhoVaccineIsForHeading(vaccineName), - subsections: extractPartsForAspect( + headline: _generateWhoVaccineIsForHeading(vaccineName), + subsections: _extractPartsForAspect( content, "SuitabilityHealthAspect", - ).concat(extractPartsForAspect(content, "ContraindicationsHealthAspect")), + ).concat(_extractPartsForAspect(content, "ContraindicationsHealthAspect")), }; const howToGetVaccine: VaccinePageSection = { - headline: extractHeadlineForAspect(content, "GettingAccessHealthAspect"), - subsections: extractPartsForAspect(content, "GettingAccessHealthAspect"), + headline: _extractHeadlineForAspect(content, "GettingAccessHealthAspect"), + subsections: _extractPartsForAspect(content, "GettingAccessHealthAspect"), }; const webpageLink: string = content.webpage; @@ -173,8 +177,9 @@ const getFilteredContentForVaccine = async ( export { getFilteredContentForVaccine, - extractPartsForAspect, - extractHeadlineForAspect, - extractDescriptionForVaccine, - generateWhoVaccineIsForHeading, + _findAspect, + _extractPartsForAspect, + _extractHeadlineForAspect, + _extractDescriptionForVaccine, + _generateWhoVaccineIsForHeading, }; From 61195030038b470dd538a189244a7dbad96f9c08 Mon Sep 17 00:00:00 2001 From: Marie Dedikova <197628467+marie-dedikova-nhs@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:27:07 +0100 Subject: [PATCH 13/27] VIA-2 MD/DB Add mocked content for flu --- wiremock/__files/flu-vaccine.json | 583 ++++++++++++++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 wiremock/__files/flu-vaccine.json diff --git a/wiremock/__files/flu-vaccine.json b/wiremock/__files/flu-vaccine.json new file mode 100644 index 00000000..8a3b6407 --- /dev/null +++ b/wiremock/__files/flu-vaccine.json @@ -0,0 +1,583 @@ +{ + "@context": "http://schema.org", + "@type": "MedicalWebPage", + "name": "Flu vaccine", + "copyrightHolder": { + "name": "Crown Copyright", + "@type": "Organization" + }, + "license": "https://developer.api.nhs.uk/terms", + "author": { + "url": "https://www.nhs.uk", + "logo": "https://assets.nhs.uk/nhsuk-cms/images/nhs-attribution.width-510.png", + "email": "nhswebsite.servicedesk@nhs.net", + "@type": "Organization", + "name": "NHS website" + }, + "about": { + "@type": "WebPage", + "name": "Flu vaccine", + "alternateName": "" + }, + "description": "Find out about the flu vaccine for adults, including who should have it, how to get it and side effects.", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/", + "genre": [ + "Vaccine" + ], + "keywords": "", + "dateModified": "2025-04-15T14:14:49+00:00", + "lastReviewed": [ + "2023-11-23T08:28:00+00:00", + "2026-11-23T08:28:00+00:00" + ], + "breadcrumb": { + "@context": "http://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + { + "@type": "ListItem", + "position": 0, + "item": { + "@id": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/", + "name": "Vaccinations", + "genre": [] + } + }, + { + "@type": "ListItem", + "position": 1, + "item": { + "@id": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/", + "name": "Flu vaccine", + "genre": [ + "Vaccine" + ] + } + } + ] + }, + "hasPart": [ + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/OverviewHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#overview", + "description": "The flu vaccine helps protect against flu. It's given every year in autumn or early winter to adults at higher risk from flu.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Who it's for" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Contraindications" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "How to get it" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Side effects" + }, + { + "text": "

The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.

", + "@type": "WebPageElement" + }, + { + "text": "

Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.

", + "@type": "WebPageElement" + }, + { + "text": "

Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.

", + "@type": "WebPageElement" + }, + { + "text": "

Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.

", + "@type": "WebPageElement" + } + ], + "headline": "" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/SuitabilityHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#who-its-for", + "description": "The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

" + }, + { + "@type": "WebPageElement", + "headline": "Health conditions that mean you're eligible for the flu vaccine", + "text": "
\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "headline": "Who should have the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/GettingAccessHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#how-to-get-it", + "description": "Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

" + }, + { + "@type": "WebPageElement", + "headline": "Frontline health or social care workers", + "text": "
\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "@type": "WebPageElement", + "headline": "Information", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "headline": "How to get the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/ContraindicationsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#contraindications", + "description": "Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

" + }, + { + "@type": "WebPageElement", + "headline": "Information", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "headline": "Who cannot have the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/IngredientsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#ingredients", + "description": "You can check the ingredients in the flu vaccine by asking to see the patient leaflet or searching for it online.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

" + }, + { + "@type": "WebPageElement", + "headline": "", + "text": ">]>)]), StructValue([('title', 'Vaccines for people aged 60 to 64'), ('body', >]>)]), StructValue([('title', 'Vaccines for people aged 18 to 59'), ('body', >]>)])]>" + } + ], + "headline": "Flu vaccine ingredients" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/SideEffectsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#side-effects", + "description": "Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

" + } + ], + "headline": "Side effects of the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/EffectivenessHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#effectiveness", + "description": "The flu vaccine helps protect you against common types of flu viruses. There's still a chance you might get flu, but it's likely to be milder.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

" + } + ], + "headline": "How well the flu vaccine works and how long it lasts" + } + ], + "relatedLink": [ + { + "@type": "LinkRole", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/", + "name": "Vaccinations", + "linkRelationship": "Navigation", + "position": 0 + } + ], + "contentSubTypes": [], + "mainEntityOfPage": [ + { + "position": 0, + "identifier": 0, + "text": "The flu vaccine helps protect against flu, which can be a serious or life-threatening illness. It's offered on the NHS every year in autumn or early winter to people at higher risk of getting seriously ill from flu.", + "name": "lead paragraph", + "@type": "WebPageElement" + }, + { + "identifier": "0", + "name": "section heading", + "position": 1, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

This page is about the flu vaccine for adults. There are also pages about the children's flu vaccine and flu jab in pregnancy.

" + } + ], + "description": "", + "hasPart": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

This page is about the flu vaccine for adults. There are also pages about the children's flu vaccine and flu jab in pregnancy.

" + } + ] + }, + { + "identifier": "0", + "name": "section heading", + "position": 2, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Health conditions that mean you're eligible for the flu vaccine", + "identifier": "18", + "mainEntity": "
\n\n\n\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "description": "The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Health conditions that mean you're eligible for the flu vaccine", + "identifier": "18", + "mainEntity": "
\n\n\n\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "hasHealthAspect": "http://schema.org/SuitabilityHealthAspect", + "headline": "Who should have the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 3, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Frontline health or social care workers", + "identifier": "18", + "mainEntity": "
\n\n\n\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "description": "Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Frontline health or social care workers", + "identifier": "18", + "mainEntity": "
\n\n\n\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "hasHealthAspect": "http://schema.org/GettingAccessHealthAspect", + "headline": "How to get the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 4, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "description": "Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "hasHealthAspect": "http://schema.org/ContraindicationsHealthAspect", + "headline": "Who cannot have the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 5, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "@type": "WebPageElement", + "position": 1, + "name": "Expander Group", + "identifier": "20", + "mainEntity": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 65 and over", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 60 to 64", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 18 to 59", + "identifier": "18", + "mainEntity": "" + } + ] + } + ], + "description": "You can check the ingredients in the flu vaccine by asking to see the patient leaflet or searching for it online.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "@type": "WebPageElement", + "position": 1, + "name": "Expander Group", + "identifier": "20", + "mainEntity": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 65 and over", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 60 to 64", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 18 to 59", + "identifier": "18", + "mainEntity": "" + } + ] + } + ], + "hasHealthAspect": "http://schema.org/IngredientsHealthAspect", + "headline": "Flu vaccine ingredients" + }, + { + "identifier": "0", + "name": "section heading", + "position": 6, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "description": "Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "hasHealthAspect": "http://schema.org/SideEffectsHealthAspect", + "headline": "Side effects of the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 7, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

More about vaccine safety

Find out more about why vaccinations are important and the safest way to protect yourself

" + } + ], + "description": "", + "hasPart": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

More about vaccine safety

Find out more about why vaccinations are important and the safest way to protect yourself

" + } + ] + }, + { + "identifier": "0", + "name": "section heading", + "position": 8, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "description": "The flu vaccine helps protect you against common types of flu viruses. There's still a chance you might get flu, but it's likely to be milder.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "hasHealthAspect": "http://schema.org/EffectivenessHealthAspect", + "headline": "How well the flu vaccine works and how long it lasts" + } + ], + "webpage": "https://www.nhs.uk/vaccinations/flu-vaccine/" +} From 59f18cbe93963f0739c20b19e7dd3296908e7852 Mon Sep 17 00:00:00 2001 From: Ankur Jain <174601014+ankur-jain-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 10:55:26 +0100 Subject: [PATCH 14/27] VIA-138 AJ/MD/DB/AS Added instructions to run e2e not from dev server as that can be slow --- README.md | 6 +++++- package.json | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cd52e6d1..78de6911 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,11 @@ npm run test ``` ### Run UI driven tests -- in headless mode +- make sure to build and run the Next.js app + ``` + npm run app + ``` +- run tests in headless mode ``` npm run e2e ``` diff --git a/package.json b/package.json index 47d00a3f..f7699068 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev --turbopack", + "app": "npm run build && npm run start", "build": "next build --experimental-build-mode compile", "build:opennext": "npx --yes @opennextjs/aws@latest build", "build:lambda": "node esbuild.config.mjs", From 4fd27737e9aabb230dd2c54d53cf5517f9d656e9 Mon Sep 17 00:00:00 2001 From: Marie Dedikova <197628467+marie-dedikova-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:33:49 +0100 Subject: [PATCH 15/27] VIA-2 DB/MD Add flu page & make whatItIsFor section optional --- .../content-cache-hydrator/handler.test.ts | 8 +- src/app/_components/vaccine/Vaccine.test.tsx | 133 +++++++++++------- src/app/_components/vaccine/Vaccine.tsx | 12 +- .../vaccines/flu/page.integration.test.tsx | 31 ++++ src/app/vaccines/flu/page.test.tsx | 37 +++++ src/app/vaccines/flu/page.tsx | 25 ++++ src/models/vaccine.ts | 2 + src/services/content-api/constants.ts | 2 + .../parsers/content-filter-service.test.ts | 20 +++ .../parsers/content-filter-service.ts | 25 +++- .../parsers/content-styling-service.test.tsx | 35 ++++- .../parsers/content-styling-service.tsx | 9 +- test-data/content-api/data.tsx | 13 ++ wiremock/mappings/mapping-vaccines.json | 13 ++ 14 files changed, 296 insertions(+), 69 deletions(-) create mode 100644 src/app/vaccines/flu/page.integration.test.tsx create mode 100644 src/app/vaccines/flu/page.test.tsx create mode 100644 src/app/vaccines/flu/page.tsx diff --git a/src/_lambda/content-cache-hydrator/handler.test.ts b/src/_lambda/content-cache-hydrator/handler.test.ts index 65d2294a..14d7788f 100644 --- a/src/_lambda/content-cache-hydrator/handler.test.ts +++ b/src/_lambda/content-cache-hydrator/handler.test.ts @@ -21,7 +21,7 @@ describe("Lambda Handler", () => { it("returns 500 when cache hydration has failed due to fetching errors", async () => { (fetchContentForVaccine as jest.Mock).mockRejectedValue(new Error("test")); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to filtering invalid content errors", async () => { @@ -29,7 +29,7 @@ describe("Lambda Handler", () => { (getFilteredContentForVaccine as jest.Mock).mockRejectedValue( new Error("test"), ); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to styling errors", async () => { @@ -38,7 +38,7 @@ describe("Lambda Handler", () => { (getStyledContentForVaccine as jest.Mock).mockRejectedValue( new Error("test"), ); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to writing errors", async () => { @@ -46,6 +46,6 @@ describe("Lambda Handler", () => { (getFilteredContentForVaccine as jest.Mock).mockResolvedValue(undefined); (getStyledContentForVaccine as jest.Mock).mockResolvedValue(undefined); (writeContentForVaccine as jest.Mock).mockRejectedValue(new Error("test")); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); }); diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index 35f40cc5..835aefca 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -3,7 +3,10 @@ import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { VaccineTypes } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; import { StyledVaccineContent } from "@src/services/content-api/parsers/content-styling-service"; -import { mockStyledContent } from "@test-data/content-api/data"; +import { + mockStyledContent, + mockStyledContentWithoutWhatSection, +} from "@test-data/content-api/data"; import { render, screen } from "@testing-library/react"; import { act } from "react"; @@ -13,79 +16,115 @@ describe("Any vaccine page", () => { const mockVaccineName = "Test-Name"; let contentPromise: Promise; - beforeEach(() => { - (getContentForVaccine as jest.Mock).mockResolvedValue(mockStyledContent); - contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); - }); - const renderVaccinePage = async () => { await act(async () => { render( - + , ); }); }; - it("should contain correct vaccine name in heading", async () => { - await renderVaccinePage(); + describe("with all sections available", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue(mockStyledContent); + contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); + }); + + it("should display correct vaccine name in heading", async () => { + await renderVaccinePage(); + + const heading = screen.getByRole("heading", { + level: 1, + name: `${mockVaccineName} vaccine`, + }); - const heading = screen.getByRole("heading", { - level: 1, - name: `${mockVaccineName} vaccine`, + expect(heading).toBeInTheDocument(); }); - expect(heading).toBeInTheDocument(); - }); + it("should display overview text", async () => { + await renderVaccinePage(); + const overviewBlock = screen.getByText("Overview text"); + expect(overviewBlock).toBeInTheDocument(); + }); - it("should contain overview text", async () => { - await renderVaccinePage(); - const overviewBlock = screen.getByText("Overview text"); - expect(overviewBlock).toBeInTheDocument(); - }); + it("should display whatItIsFor expander block", async () => { + await renderVaccinePage(); - it("should contain whatItIsFor expander block", async () => { - await renderVaccinePage(); + const heading = screen.getByText("what-heading"); + const content = screen.getByText("What Section styled component"); - const heading = screen.getByText("what-heading"); - const content = screen.getByText("What Section styled component"); + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); - }); + it("should display whoVaccineIsFor expander block", async () => { + await renderVaccinePage(); - it("should contain whoVaccineIsFor expander block", async () => { - await renderVaccinePage(); + const heading = screen.getByText("who-heading"); + const content = screen.getByRole("heading", { + level: 2, + name: "Who Section styled component", + }); - const heading = screen.getByText("who-heading"); - const content = screen.getByRole("heading", { - level: 2, - name: "Who Section styled component", + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); - }); + it("should display howToGetVaccine expander block", async () => { + await renderVaccinePage(); - it("should contain howToGetVaccine expander block", async () => { - await renderVaccinePage(); + const heading = screen.getByText("how-heading"); + const content = screen.getByText("How Section styled component"); - const heading = screen.getByText("how-heading"); - const content = screen.getByText("How Section styled component"); + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); + it("should display webpage link", async () => { + await renderVaccinePage(); + + const webpageLink = screen.getByRole("link", { + name: `Learn more about the ${mockVaccineName} vaccination on nhs.uk`, + }); + + expect(webpageLink).toBeInTheDocument(); + expect(webpageLink).toHaveAttribute("href", "https://www.test.com/"); + }); }); - it("should contain webpage link", async () => { - await renderVaccinePage(); + describe("without whatItIsFor section", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + mockStyledContentWithoutWhatSection, + ); + contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); + }); + + it("should not display whatItIsFor section", async () => { + await renderVaccinePage(); - const webpageLink = screen.getByRole("link", { - name: `Learn more about the ${mockVaccineName} vaccination on nhs.uk`, + const heading: HTMLElement | null = screen.queryByText("what-heading"); + const content: HTMLElement | null = screen.queryByText( + "What Section styled component", + ); + + expect(heading).not.toBeInTheDocument(); + expect(content).not.toBeInTheDocument(); }); - expect(webpageLink).toBeInTheDocument(); - expect(webpageLink).toHaveAttribute("href", "https://www.test.com/"); + it("should display whoVaccineIsFor section", async () => { + await renderVaccinePage(); + + const heading: HTMLElement = screen.getByText("who-heading"); + const content: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Who Section styled component", + }); + + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); }); }); diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 66474986..e6f14bd5 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -1,6 +1,5 @@ "use client"; -import { VaccineTypes } from "@src/models/vaccine"; import { JSX, use } from "react"; import Details from "@src/app/_components/nhs-frontend/Details"; import { useVaccineContentContextValue } from "@src/app/_components/providers/VaccineContentProvider"; @@ -8,7 +7,6 @@ import { StyledVaccineContent } from "@src/services/content-api/parsers/content- interface VaccineProps { name: string; - vaccine: VaccineTypes; } const Vaccine = (props: VaccineProps): JSX.Element => { @@ -22,10 +20,12 @@ const Vaccine = (props: VaccineProps): JSX.Element => {

More information

-
+ {styledContent.whatVaccineIsFor ? ( +
+ ) : undefined}
{ + (configProvider as jest.Mock).mockImplementation(() => ({ + CONTENT_CACHE_PATH: "wiremock/__files/", + PINO_LOG_LEVEL: "info", + })); + + it("should display Flu content from embedded vaccine component", async () => { + await act(async () => { + render(VaccineFlu()); + }); + + const fluHeading: HTMLElement = await screen.findByRole("heading", { + level: 1, + name: "Flu vaccine", + }); + expect(fluHeading).toBeInTheDocument(); + + const overview: HTMLElement = await screen.findByTestId("overview-text"); + expect(overview).toHaveTextContent( + mockFluVaccineContent.mainEntityOfPage[0].text as string, + ); + }); +}); diff --git a/src/app/vaccines/flu/page.test.tsx b/src/app/vaccines/flu/page.test.tsx new file mode 100644 index 00000000..4a68a95a --- /dev/null +++ b/src/app/vaccines/flu/page.test.tsx @@ -0,0 +1,37 @@ +import { VaccineTypes } from "@src/models/vaccine"; +import { configProvider } from "@src/utils/config"; +import { render, screen } from "@testing-library/react"; +import VaccineFlu from "@src/app/vaccines/flu/page"; +import Vaccine from "@src/app/_components/vaccine/Vaccine"; + +jest.mock("@src/utils/config"); +jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); + +describe("Flu vaccine page", () => { + (configProvider as jest.Mock).mockImplementation(() => ({ + CONTENT_CACHE_PATH: "wiremock/__files/", + PINO_LOG_LEVEL: "info", + })); + + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; + + render(VaccineFlu()); + + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it("should contain vaccine component", () => { + render(VaccineFlu()); + + expect(Vaccine).toHaveBeenCalledWith( + { + name: "Flu", + vaccine: VaccineTypes.FLU, + }, + undefined, + ); + }); +}); diff --git a/src/app/vaccines/flu/page.tsx b/src/app/vaccines/flu/page.tsx new file mode 100644 index 00000000..c72eddca --- /dev/null +++ b/src/app/vaccines/flu/page.tsx @@ -0,0 +1,25 @@ +import BackLink from "@src/app/_components/nhs-frontend/BackLink"; +import { JSX } from "react"; + +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; +import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { VaccineContentProvider } from "@src/app/_components/providers/VaccineContentProvider"; + +export const dynamic = "force-dynamic"; + +const VaccineFlu = (): JSX.Element => { + const contentPromise = getContentForVaccine(VaccineTypes.FLU); + + return ( +
+ Flu Vaccine - NHS App + + + + +
+ ); +}; + +export default VaccineFlu; diff --git a/src/models/vaccine.ts b/src/models/vaccine.ts index 34722b1f..b9398603 100644 --- a/src/models/vaccine.ts +++ b/src/models/vaccine.ts @@ -1,11 +1,13 @@ enum VaccineTypes { SIX_IN_ONE = "SIX_IN_ONE", RSV = "RSV", + FLU = "FLU", } const VaccineDisplayNames: Record = { [VaccineTypes.SIX_IN_ONE]: "6-in-1", [VaccineTypes.RSV]: "RSV", + [VaccineTypes.FLU]: "Flu", }; export { VaccineTypes, VaccineDisplayNames }; diff --git a/src/services/content-api/constants.ts b/src/services/content-api/constants.ts index 16279ce4..ee3c61b3 100644 --- a/src/services/content-api/constants.ts +++ b/src/services/content-api/constants.ts @@ -3,11 +3,13 @@ import { VaccineTypes } from "@src/models/vaccine"; enum VaccineContentPaths { SIX_IN_ONE = "6-in-1-vaccine", RSV = "rsv-vaccine", + FLU = "flu-vaccine", } const vaccineTypeToPath: Record = { [VaccineTypes.SIX_IN_ONE]: VaccineContentPaths.SIX_IN_ONE, [VaccineTypes.RSV]: VaccineContentPaths.RSV, + [VaccineTypes.FLU]: VaccineContentPaths.FLU, }; const CONTENT_API_VACCINATIONS_PATH = "/nhs-website-content/vaccinations"; diff --git a/src/services/content-api/parsers/content-filter-service.test.ts b/src/services/content-api/parsers/content-filter-service.test.ts index e5f35b9a..c1967fd9 100644 --- a/src/services/content-api/parsers/content-filter-service.test.ts +++ b/src/services/content-api/parsers/content-filter-service.test.ts @@ -7,6 +7,7 @@ import { ContentApiVaccineResponse, _findAspect, MainEntityOfPage, + VaccinePageContent, } from "@src/services/content-api/parsers/content-filter-service"; import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; import { VaccineTypes } from "@src/models/vaccine"; @@ -278,5 +279,24 @@ describe("Content Filter", () => { expect.objectContaining(expectedWebpageLink), ); }); + + it("should not return whatVaccineIsFor section when BenefitsHealthAspect is missing", async () => { + const responseWithoutBenefitsHealthAspect: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: + genericVaccineContentAPIResponse.mainEntityOfPage.filter( + (page) => + page.hasHealthAspect !== "http://schema.org/BenefitsHealthAspect", + ), + }; + + const pageCopyForFlu: VaccinePageContent = + await getFilteredContentForVaccine( + VaccineTypes.FLU, + JSON.stringify(responseWithoutBenefitsHealthAspect), + ); + + expect(pageCopyForFlu.whatVaccineIsFor).toBeUndefined(); + }); }); }); diff --git a/src/services/content-api/parsers/content-filter-service.ts b/src/services/content-api/parsers/content-filter-service.ts index e58a61e4..8ceb756b 100644 --- a/src/services/content-api/parsers/content-filter-service.ts +++ b/src/services/content-api/parsers/content-filter-service.ts @@ -46,7 +46,7 @@ export type ContentApiVaccineResponse = { dateModified: string; lastReviewed: string[]; breadcrumb: object; - hasPart: object; + hasPart: object[]; relatedLink: object; contentSubTypes: object; }; @@ -64,7 +64,7 @@ export type VaccinePageSection = { export type VaccinePageContent = { overview: string; - whatVaccineIsFor: VaccinePageSection; + whatVaccineIsFor?: VaccinePageSection; whoVaccineIsFor: VaccinePageSection; howToGetVaccine: VaccinePageSection; webpageLink: string; @@ -83,6 +83,16 @@ const _findAspect = ( return aspect; }; +const _hasHealthAspect = ( + response: ContentApiVaccineResponse, + aspectName: Aspect, +): boolean => { + const aspect: MainEntityOfPage | undefined = response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.hasHealthAspect?.endsWith(aspectName), + ); + return !!aspect; +}; + const _extractHeadlineForAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, @@ -146,10 +156,13 @@ const getFilteredContentForVaccine = async ( "lead paragraph", ); - const whatVaccineIsFor: VaccinePageSection = { - headline: _extractHeadlineForAspect(content, "BenefitsHealthAspect"), - subsections: _extractPartsForAspect(content, "BenefitsHealthAspect"), - }; + let whatVaccineIsFor; + if (_hasHealthAspect(content, "BenefitsHealthAspect")) { + whatVaccineIsFor = { + headline: _extractHeadlineForAspect(content, "BenefitsHealthAspect"), + subsections: _extractPartsForAspect(content, "BenefitsHealthAspect"), + }; + } const whoVaccineIsFor: VaccinePageSection = { headline: _generateWhoVaccineIsForHeading(vaccineName), diff --git a/src/services/content-api/parsers/content-styling-service.test.tsx b/src/services/content-api/parsers/content-styling-service.test.tsx index 2f7aaaf5..c60dcf0d 100644 --- a/src/services/content-api/parsers/content-styling-service.test.tsx +++ b/src/services/content-api/parsers/content-styling-service.test.tsx @@ -165,7 +165,7 @@ describe("ContentStylingService", () => { expect(styledVaccineContent).not.toBeNull(); expect(styledVaccineContent.overview).toEqual("This is an overview"); - expect(styledVaccineContent.whatVaccineIsFor.heading).toEqual( + expect(styledVaccineContent.whatVaccineIsFor?.heading).toEqual( "What Vaccine Is For", ); expect(styledVaccineContent.whoVaccineIsFor.heading).toEqual( @@ -175,7 +175,7 @@ describe("ContentStylingService", () => { "How to get this Vaccine", ); expect( - isValidElement(styledVaccineContent.whatVaccineIsFor.component), + isValidElement(styledVaccineContent.whatVaccineIsFor?.component), ).toBe(true); expect( isValidElement(styledVaccineContent.whoVaccineIsFor.component), @@ -185,6 +185,37 @@ describe("ContentStylingService", () => { ).toBe(true); expect(styledVaccineContent.webpageLink).toEqual("This is a link"); }); + + it("should return styled content without what-section when what-section is missing", async () => { + const mockWhoSection: VaccinePageSection = { + headline: "Who is this Vaccine For", + subsections: [mockMarkdownSubsection, mockNonUrgentSubsection], + }; + const mockHowSection: VaccinePageSection = { + headline: "How to get this Vaccine", + subsections: [mockMarkdownSubsection, mockNonUrgentSubsection], + }; + const mockContent: VaccinePageContent = { + overview: "This is an overview", + whoVaccineIsFor: mockWhoSection, + howToGetVaccine: mockHowSection, + webpageLink: "This is a link", + }; + + const styledVaccineContent: StyledVaccineContent = + await getStyledContentForVaccine(VaccineTypes.RSV, mockContent); + + expect(styledVaccineContent).not.toBeNull(); + expect(styledVaccineContent.overview).toEqual("This is an overview"); + expect(styledVaccineContent.whatVaccineIsFor).toBeUndefined(); + expect( + isValidElement(styledVaccineContent.whoVaccineIsFor.component), + ).toBe(true); + expect( + isValidElement(styledVaccineContent.howToGetVaccine.component), + ).toBe(true); + expect(styledVaccineContent.webpageLink).toEqual("This is a link"); + }); }); describe("extractHeadingAndContent", () => { diff --git a/src/services/content-api/parsers/content-styling-service.tsx b/src/services/content-api/parsers/content-styling-service.tsx index d0a4995f..7185aa71 100644 --- a/src/services/content-api/parsers/content-styling-service.tsx +++ b/src/services/content-api/parsers/content-styling-service.tsx @@ -29,7 +29,7 @@ export type NonUrgentContent = { heading: string; content: string }; export type StyledVaccineContent = { overview: string; - whatVaccineIsFor: StyledPageSection; + whatVaccineIsFor?: StyledPageSection; whoVaccineIsFor: StyledPageSection; howToGetVaccine: StyledPageSection; webpageLink: string; @@ -91,9 +91,10 @@ const getStyledContentForVaccine = async ( filteredContent: VaccinePageContent, ): Promise => { const overview: string = filteredContent.overview; - const whatVaccineIsFor: StyledPageSection = styleSection( - filteredContent.whatVaccineIsFor, - ); + let whatVaccineIsFor; + if (filteredContent.whatVaccineIsFor) { + whatVaccineIsFor = styleSection(filteredContent.whatVaccineIsFor); + } const whoVaccineIsFor: StyledPageSection = styleSection( filteredContent.whoVaccineIsFor, ); diff --git a/test-data/content-api/data.tsx b/test-data/content-api/data.tsx index 6c818342..44d7b674 100644 --- a/test-data/content-api/data.tsx +++ b/test-data/content-api/data.tsx @@ -574,3 +574,16 @@ export const mockStyledContent: StyledVaccineContent = { }, webpageLink: "https://www.test.com/" }; + +export const mockStyledContentWithoutWhatSection: StyledVaccineContent = { + overview: "Overview text", + whoVaccineIsFor: { + heading: "who-heading", + component:

Who Section styled component

+ }, + howToGetVaccine: { + heading: "how-heading", + component:
How Section styled component
+ }, + webpageLink: "https://www.test.com/" +}; diff --git a/wiremock/mappings/mapping-vaccines.json b/wiremock/mappings/mapping-vaccines.json index c482374d..9e39e5a3 100644 --- a/wiremock/mappings/mapping-vaccines.json +++ b/wiremock/mappings/mapping-vaccines.json @@ -25,6 +25,19 @@ "Content-Type": "application/json" } } + }, + { + "request": { + "method": "GET", + "url": "/nhs-website-content/vaccinations/flu-vaccine" + }, + "response": { + "status": 200, + "bodyFileName": "flu-vaccine.json", + "headers": { + "Content-Type": "application/json" + } + } } ] } From 3e26cb0678c4b60a7ff3fa10c0be1e0b806a7371 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 15:08:22 +0100 Subject: [PATCH 16/27] VIA-2 MD/DB Exclude .open-next build folder from jest test scanning --- jest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index e5ee0175..46e2cef8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -19,7 +19,7 @@ const config: Config = { "^@src/(.*)$": "/src/$1", "^@test-data/(.*)$": "/mocks/$1" }, - testPathIgnorePatterns: ["/e2e/"], + testPathIgnorePatterns: ["/e2e/", "/.open-next/"], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async From bc8fbc65e18d31d83513d388526817d7f3d675c0 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 15:10:19 +0100 Subject: [PATCH 17/27] VIA-2 MD/DB Add tests for hasHealthAspect method --- .../parsers/content-filter-service.test.ts | 33 ++++++++++++++----- .../parsers/content-filter-service.ts | 1 + test-data/content-api/helpers.ts | 16 +++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 test-data/content-api/helpers.ts diff --git a/src/services/content-api/parsers/content-filter-service.test.ts b/src/services/content-api/parsers/content-filter-service.test.ts index c1967fd9..ab4cdb81 100644 --- a/src/services/content-api/parsers/content-filter-service.test.ts +++ b/src/services/content-api/parsers/content-filter-service.test.ts @@ -8,9 +8,11 @@ import { _findAspect, MainEntityOfPage, VaccinePageContent, + _hasHealthAspect, } from "@src/services/content-api/parsers/content-filter-service"; import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; import { VaccineTypes } from "@src/models/vaccine"; +import { contentWithoutBenefitsHealthAspect } from "@test-data/content-api/helpers"; describe("Content Filter", () => { describe("_extractDescriptionForVaccine", () => { @@ -157,6 +159,27 @@ describe("Content Filter", () => { }); }); + describe("_hasHealthAspect", () => { + it("should return true if healthAspect exists in content", () => { + const hasHealthAspect = _hasHealthAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + expect(hasHealthAspect).toBeTruthy(); + }); + + it("should return false if healthAspect does not exist in content", () => { + const responseWithoutBenefitsHealthAspect = + contentWithoutBenefitsHealthAspect(); + + const hasHealthAspect = _hasHealthAspect( + responseWithoutBenefitsHealthAspect, + "BenefitsHealthAspect", + ); + expect(hasHealthAspect).toBeFalsy(); + }); + }); + describe("getFilteredContentForVaccine", () => { it("should return overview text from lead paragraph mainEntityOfPage object", async () => { const expectedOverview = { @@ -281,14 +304,8 @@ describe("Content Filter", () => { }); it("should not return whatVaccineIsFor section when BenefitsHealthAspect is missing", async () => { - const responseWithoutBenefitsHealthAspect: ContentApiVaccineResponse = { - ...genericVaccineContentAPIResponse, - mainEntityOfPage: - genericVaccineContentAPIResponse.mainEntityOfPage.filter( - (page) => - page.hasHealthAspect !== "http://schema.org/BenefitsHealthAspect", - ), - }; + const responseWithoutBenefitsHealthAspect = + contentWithoutBenefitsHealthAspect(); const pageCopyForFlu: VaccinePageContent = await getFilteredContentForVaccine( diff --git a/src/services/content-api/parsers/content-filter-service.ts b/src/services/content-api/parsers/content-filter-service.ts index 8ceb756b..d62100a7 100644 --- a/src/services/content-api/parsers/content-filter-service.ts +++ b/src/services/content-api/parsers/content-filter-service.ts @@ -191,6 +191,7 @@ const getFilteredContentForVaccine = async ( export { getFilteredContentForVaccine, _findAspect, + _hasHealthAspect, _extractPartsForAspect, _extractHeadlineForAspect, _extractDescriptionForVaccine, diff --git a/test-data/content-api/helpers.ts b/test-data/content-api/helpers.ts new file mode 100644 index 00000000..590f61f3 --- /dev/null +++ b/test-data/content-api/helpers.ts @@ -0,0 +1,16 @@ +import { ContentApiVaccineResponse } from "@src/services/content-api/parsers/content-filter-service"; +import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; + +const contentWithoutBenefitsHealthAspect = () => { + const responseWithoutBenefitsHealthAspect: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: + genericVaccineContentAPIResponse.mainEntityOfPage.filter( + (page) => + page.hasHealthAspect !== "http://schema.org/BenefitsHealthAspect" + ) + }; + return responseWithoutBenefitsHealthAspect; +} + +export {contentWithoutBenefitsHealthAspect} From a90647f3f1e8c613098dd42a83930273ad33ee94 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 15:40:42 +0100 Subject: [PATCH 18/27] VIA-2 MD/DB Fix WhoVaccineIsFor section to include "Who cannot have" heading --- .../parsers/content-filter-service.test.ts | 5 +++++ .../parsers/content-filter-service.ts | 22 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/services/content-api/parsers/content-filter-service.test.ts b/src/services/content-api/parsers/content-filter-service.test.ts index ab4cdb81..0a753428 100644 --- a/src/services/content-api/parsers/content-filter-service.test.ts +++ b/src/services/content-api/parsers/content-filter-service.test.ts @@ -235,6 +235,11 @@ describe("Content Filter", () => { name: "markdown", text: "

Suitability Health Aspect paragraph 3

Suitability Health Aspect paragraph 4

", }, + { + headline: "Contraindications Health Aspect headline", + name: "", + text: "", + }, { headline: "", name: "markdown", diff --git a/src/services/content-api/parsers/content-filter-service.ts b/src/services/content-api/parsers/content-filter-service.ts index d62100a7..716ddbf8 100644 --- a/src/services/content-api/parsers/content-filter-service.ts +++ b/src/services/content-api/parsers/content-filter-service.ts @@ -146,6 +146,21 @@ const _generateWhoVaccineIsForHeading = (vaccineType: VaccineTypes): string => { return `Who should have the ${VaccineDisplayNames[vaccineType]} vaccine`; }; +function _extractHeadlineForContraindicationsAspect( + content: ContentApiVaccineResponse, +) { + return [ + { + headline: _extractHeadlineForAspect( + content, + "ContraindicationsHealthAspect", + ), + text: "", + name: "", + }, + ]; +} + const getFilteredContentForVaccine = async ( vaccineName: VaccineTypes, apiContent: string, @@ -166,10 +181,9 @@ const getFilteredContentForVaccine = async ( const whoVaccineIsFor: VaccinePageSection = { headline: _generateWhoVaccineIsForHeading(vaccineName), - subsections: _extractPartsForAspect( - content, - "SuitabilityHealthAspect", - ).concat(_extractPartsForAspect(content, "ContraindicationsHealthAspect")), + subsections: _extractPartsForAspect(content, "SuitabilityHealthAspect") + .concat(_extractHeadlineForContraindicationsAspect(content)) + .concat(_extractPartsForAspect(content, "ContraindicationsHealthAspect")), }; const howToGetVaccine: VaccinePageSection = { From 3824d3eccd93b5481fb920eb709c0bfbd38bd3d7 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:41:36 +0100 Subject: [PATCH 19/27] VIA-2 MD/DB Clean up unused vaccine type variable --- src/app/vaccines/6-in-1/page.test.tsx | 2 -- src/app/vaccines/6-in-1/page.tsx | 5 +---- src/app/vaccines/flu/page.test.tsx | 2 -- src/app/vaccines/flu/page.tsx | 2 +- src/app/vaccines/rsv/page.test.tsx | 2 -- src/app/vaccines/rsv/page.tsx | 2 +- 6 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/app/vaccines/6-in-1/page.test.tsx b/src/app/vaccines/6-in-1/page.test.tsx index 3b267f6f..351093b8 100644 --- a/src/app/vaccines/6-in-1/page.test.tsx +++ b/src/app/vaccines/6-in-1/page.test.tsx @@ -1,4 +1,3 @@ -import { VaccineTypes } from "@src/models/vaccine"; import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import Vaccine6in1 from "@src/app/vaccines/6-in-1/page"; @@ -28,7 +27,6 @@ describe("6-in-1 vaccine page", () => { expect(Vaccine).toHaveBeenCalledWith( { name: "6-in-1", - vaccine: VaccineTypes.SIX_IN_ONE, }, undefined, ); diff --git a/src/app/vaccines/6-in-1/page.tsx b/src/app/vaccines/6-in-1/page.tsx index ffdd0173..7e6794b7 100644 --- a/src/app/vaccines/6-in-1/page.tsx +++ b/src/app/vaccines/6-in-1/page.tsx @@ -19,10 +19,7 @@ const Vaccine6in1 = (): JSX.Element => { 6-in-1 Vaccine - NHS App - +
); diff --git a/src/app/vaccines/flu/page.test.tsx b/src/app/vaccines/flu/page.test.tsx index 4a68a95a..48bc9824 100644 --- a/src/app/vaccines/flu/page.test.tsx +++ b/src/app/vaccines/flu/page.test.tsx @@ -1,4 +1,3 @@ -import { VaccineTypes } from "@src/models/vaccine"; import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import VaccineFlu from "@src/app/vaccines/flu/page"; @@ -29,7 +28,6 @@ describe("Flu vaccine page", () => { expect(Vaccine).toHaveBeenCalledWith( { name: "Flu", - vaccine: VaccineTypes.FLU, }, undefined, ); diff --git a/src/app/vaccines/flu/page.tsx b/src/app/vaccines/flu/page.tsx index c72eddca..fe2c8326 100644 --- a/src/app/vaccines/flu/page.tsx +++ b/src/app/vaccines/flu/page.tsx @@ -16,7 +16,7 @@ const VaccineFlu = (): JSX.Element => { Flu Vaccine - NHS App - +
); diff --git a/src/app/vaccines/rsv/page.test.tsx b/src/app/vaccines/rsv/page.test.tsx index 9f516bc3..0da32115 100644 --- a/src/app/vaccines/rsv/page.test.tsx +++ b/src/app/vaccines/rsv/page.test.tsx @@ -1,4 +1,3 @@ -import { VaccineTypes } from "@src/models/vaccine"; import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import VaccineRsv from "@src/app/vaccines/rsv/page"; @@ -29,7 +28,6 @@ describe("RSV vaccine page", () => { expect(Vaccine).toHaveBeenCalledWith( { name: "RSV", - vaccine: VaccineTypes.RSV, }, undefined, ); diff --git a/src/app/vaccines/rsv/page.tsx b/src/app/vaccines/rsv/page.tsx index fbc947f2..3ef92907 100644 --- a/src/app/vaccines/rsv/page.tsx +++ b/src/app/vaccines/rsv/page.tsx @@ -16,7 +16,7 @@ const VaccineRsv = (): JSX.Element => { RSV Vaccine - NHS App - + ); From 6413b8b3ba07a6f123212118d1c349a541bd68fd Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:52:58 +0100 Subject: [PATCH 20/27] VIA-2 MD/DB Add Flu vaccine link to Hub and Schedule page --- src/app/page.test.tsx | 7 +++++++ src/app/page.tsx | 1 + src/app/schedule/page.test.tsx | 26 ++++++++++++++++++-------- src/app/schedule/page.tsx | 2 ++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index 934d61fe..19231d60 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -50,4 +50,11 @@ describe("Vaccination Hub Page", () => { expect(link).toBeInTheDocument(); expect(link.getAttribute("href")).toEqual("/vaccines/rsv"); }); + + it("renders Flu vaccine link", async () => { + const link: HTMLElement = screen.getByRole("link", { name: "Flu vaccine" }); + + expect(link).toBeInTheDocument(); + expect(link.getAttribute("href")).toEqual("/vaccines/flu"); + }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index c2c33875..6d702355 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,6 +28,7 @@ const VaccinationsHub = () => {

+
diff --git a/src/app/schedule/page.test.tsx b/src/app/schedule/page.test.tsx index 8a96c2cd..b36b7b50 100644 --- a/src/app/schedule/page.test.tsx +++ b/src/app/schedule/page.test.tsx @@ -19,9 +19,19 @@ describe("Schedule Page", () => { expect(heading).toBeInTheDocument(); }); + it("renders static description text", async () => { + const description: HTMLElement = screen.getByText( + "Find out about vaccinations for babies, children and adults, including why they're important and how to get them.", + ); + + expect(description).toBeInTheDocument(); + }); + it("renders the section headings", async () => { const expectedSectionText: string[] = [ "Vaccines for babies under 1 year old", + "Vaccines for Adults", + "Vaccines for Seasonal diseases", ]; expectedSectionText.forEach((headingText) => { @@ -34,20 +44,20 @@ describe("Schedule Page", () => { }); }); - it("renders static description text", async () => { - const description: HTMLElement = screen.getByText( - "Find out about vaccinations for babies, children and adults, including why they're important and how to get them.", - ); - - expect(description).toBeInTheDocument(); - }); - it("renders card component with props", async () => { const expectedVaccines = [ { name: "6-in-1 vaccine", href: "/vaccines/6-in-1", }, + { + name: "RSV vaccine", + href: "/vaccines/rsv", + }, + { + name: "Flu vaccine", + href: "/vaccines/flu", + }, ]; expectedVaccines.forEach((vaccine) => { diff --git a/src/app/schedule/page.tsx b/src/app/schedule/page.tsx index 9d01242c..ad84a606 100644 --- a/src/app/schedule/page.tsx +++ b/src/app/schedule/page.tsx @@ -13,6 +13,8 @@ const Schedule = () => { Find out about vaccinations for babies, children and adults, including why they're important and how to get them.

+

Vaccines for Seasonal diseases

+

Vaccines for Adults

Vaccines for babies under 1 year old

From cbea4ce8627b122854d0044fc4628efc23cf216b Mon Sep 17 00:00:00 2001 From: Marie Dedikova <197628467+marie-dedikova-nhs@users.noreply.github.com> Date: Wed, 23 Apr 2025 18:03:32 +0100 Subject: [PATCH 21/27] VIA-2 DB/MD Add vaccine error page without hyperlink --- src/app/vaccines/flu/page.tsx | 5 +- src/app/vaccines/vaccine-error/page.test.tsx | 51 ++++++++++++++++++++ src/app/vaccines/vaccine-error/page.tsx | 28 +++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/app/vaccines/vaccine-error/page.test.tsx create mode 100644 src/app/vaccines/vaccine-error/page.tsx diff --git a/src/app/vaccines/flu/page.tsx b/src/app/vaccines/flu/page.tsx index fe2c8326..daee03be 100644 --- a/src/app/vaccines/flu/page.tsx +++ b/src/app/vaccines/flu/page.tsx @@ -5,11 +5,14 @@ import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; import { VaccineContentProvider } from "@src/app/_components/providers/VaccineContentProvider"; +import { StyledVaccineContent } from "@src/services/content-api/parsers/content-styling-service"; export const dynamic = "force-dynamic"; const VaccineFlu = (): JSX.Element => { - const contentPromise = getContentForVaccine(VaccineTypes.FLU); + const contentPromise: Promise = getContentForVaccine( + VaccineTypes.FLU, + ); return (
diff --git a/src/app/vaccines/vaccine-error/page.test.tsx b/src/app/vaccines/vaccine-error/page.test.tsx new file mode 100644 index 00000000..769e0357 --- /dev/null +++ b/src/app/vaccines/vaccine-error/page.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import VaccineError from "@src/app/vaccines/vaccine-error/page"; +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; + +describe("VaccineError", () => { + it("should display back button", () => { + const pathToSchedulePage: string = "/schedule"; + + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const linkToSchedulePage: HTMLElement = screen.getByRole("link", { + name: "Go back", + }); + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it.each([VaccineTypes.FLU, VaccineTypes.RSV, VaccineTypes.SIX_IN_ONE])( + `should display vaccine name in error heading`, + (vaccineType: VaccineTypes) => { + render(VaccineError({ vaccineType: vaccineType })); + + const heading: HTMLElement = screen.getByRole("heading", { + level: 1, + name: `${VaccineDisplayNames[vaccineType]} vaccine`, + }); + + expect(heading).toBeInTheDocument(); + }, + ); + + it("should display subheading", () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const subheading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Vaccine content is unavailable", + }); + + expect(subheading).toBeInTheDocument(); + }); + + it("should display error text", () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const text: HTMLElement = screen.getByText( + "Sorry, there is a problem showing vaccine information. Come back later, or read about vaccinations on NHS.uk.", + ); + + expect(text).toBeInTheDocument(); + }); +}); diff --git a/src/app/vaccines/vaccine-error/page.tsx b/src/app/vaccines/vaccine-error/page.tsx new file mode 100644 index 00000000..1b015d69 --- /dev/null +++ b/src/app/vaccines/vaccine-error/page.tsx @@ -0,0 +1,28 @@ +import { JSX } from "react"; +import BackLink from "@src/app/_components/nhs-frontend/BackLink"; +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; +import styles from "@src/app/styles.module.css"; + +interface VaccineProps { + vaccineType: VaccineTypes; +} + +const VaccineError = (props: VaccineProps): JSX.Element => { + return ( +
+ +

{`${VaccineDisplayNames[props.vaccineType]} vaccine`}

+
+
+

Vaccine content is unavailable

+
+

+ Sorry, there is a problem showing vaccine information. Come back + later, or read about vaccinations on NHS.uk. +

+
+
+ ); +}; + +export default VaccineError; From b51be852b5c8efdad055e28eca24848448393708 Mon Sep 17 00:00:00 2001 From: Marie Dedikova <197628467+marie-dedikova-nhs@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:54:01 +0100 Subject: [PATCH 22/27] VIA-2 DB/MD Add link to the error page for vaccine pages & add a few missing types to a test --- src/app/_components/vaccine/Vaccine.test.tsx | 20 ++++++++++++-------- src/app/vaccines/vaccine-error/page.test.tsx | 15 ++++++++++++++- src/app/vaccines/vaccine-error/page.tsx | 7 ++++--- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index 835aefca..960c6a3a 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -45,15 +45,17 @@ describe("Any vaccine page", () => { it("should display overview text", async () => { await renderVaccinePage(); - const overviewBlock = screen.getByText("Overview text"); + const overviewBlock: HTMLElement = screen.getByText("Overview text"); expect(overviewBlock).toBeInTheDocument(); }); it("should display whatItIsFor expander block", async () => { await renderVaccinePage(); - const heading = screen.getByText("what-heading"); - const content = screen.getByText("What Section styled component"); + const heading: HTMLElement = screen.getByText("what-heading"); + const content: HTMLElement = screen.getByText( + "What Section styled component", + ); expect(heading).toBeInTheDocument(); expect(content).toBeInTheDocument(); @@ -62,8 +64,8 @@ describe("Any vaccine page", () => { it("should display whoVaccineIsFor expander block", async () => { await renderVaccinePage(); - const heading = screen.getByText("who-heading"); - const content = screen.getByRole("heading", { + const heading: HTMLElement = screen.getByText("who-heading"); + const content: HTMLElement = screen.getByRole("heading", { level: 2, name: "Who Section styled component", }); @@ -75,8 +77,10 @@ describe("Any vaccine page", () => { it("should display howToGetVaccine expander block", async () => { await renderVaccinePage(); - const heading = screen.getByText("how-heading"); - const content = screen.getByText("How Section styled component"); + const heading: HTMLElement = screen.getByText("how-heading"); + const content: HTMLElement = screen.getByText( + "How Section styled component", + ); expect(heading).toBeInTheDocument(); expect(content).toBeInTheDocument(); @@ -85,7 +89,7 @@ describe("Any vaccine page", () => { it("should display webpage link", async () => { await renderVaccinePage(); - const webpageLink = screen.getByRole("link", { + const webpageLink: HTMLElement = screen.getByRole("link", { name: `Learn more about the ${mockVaccineName} vaccination on nhs.uk`, }); diff --git a/src/app/vaccines/vaccine-error/page.test.tsx b/src/app/vaccines/vaccine-error/page.test.tsx index 769e0357..094e19cb 100644 --- a/src/app/vaccines/vaccine-error/page.test.tsx +++ b/src/app/vaccines/vaccine-error/page.test.tsx @@ -43,9 +43,22 @@ describe("VaccineError", () => { render(VaccineError({ vaccineType: VaccineTypes.FLU })); const text: HTMLElement = screen.getByText( - "Sorry, there is a problem showing vaccine information. Come back later, or read about vaccinations on NHS.uk.", + /Sorry, there is a problem showing vaccine/, ); expect(text).toBeInTheDocument(); }); + + it("renders link in error text", async () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const link: HTMLElement = screen.getByRole("link", { + name: "vaccinations on NHS.uk", + }); + + expect(link).toBeInTheDocument(); + expect(link.getAttribute("href")).toEqual( + "https://www.nhs.uk/vaccinations/", + ); + }); }); diff --git a/src/app/vaccines/vaccine-error/page.tsx b/src/app/vaccines/vaccine-error/page.tsx index 1b015d69..22e2f2e2 100644 --- a/src/app/vaccines/vaccine-error/page.tsx +++ b/src/app/vaccines/vaccine-error/page.tsx @@ -7,18 +7,19 @@ interface VaccineProps { vaccineType: VaccineTypes; } -const VaccineError = (props: VaccineProps): JSX.Element => { +const VaccineError = ({ vaccineType }: VaccineProps): JSX.Element => { return (
-

{`${VaccineDisplayNames[props.vaccineType]} vaccine`}

+

{`${VaccineDisplayNames[vaccineType]} vaccine`}

Vaccine content is unavailable

Sorry, there is a problem showing vaccine information. Come back - later, or read about vaccinations on NHS.uk. + later, or read about{" "} + vaccinations on NHS.uk.

From f497ac876850009c99c7d1425eb85dc6cc1a605f Mon Sep 17 00:00:00 2001 From: Marie Dedikova <197628467+marie-dedikova-nhs@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:17:57 +0100 Subject: [PATCH 23/27] VIA-2 DB/MD Improve UI of error page and render error page when there is error on flu vaccine page --- package-lock.json | 17 ++++- package.json | 3 +- src/app/vaccines/flu/page.test.tsx | 70 +++++++++++++------- src/app/vaccines/flu/page.tsx | 10 ++- src/app/vaccines/vaccine-error/page.test.tsx | 11 --- src/app/vaccines/vaccine-error/page.tsx | 29 ++++---- 6 files changed, 86 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ae52235..e89772da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "openid-client": "^6.4.2", "pino": "^9.6.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-error-boundary": "^5.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -1696,7 +1697,6 @@ "version": "7.26.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -10521,6 +10521,18 @@ "react": "^19.0.0" } }, + "node_modules/react-error-boundary": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", + "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -10578,7 +10590,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regexp.prototype.flags": { diff --git a/package.json b/package.json index f7699068..33e1c51f 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "openid-client": "^6.4.2", "pino": "^9.6.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-error-boundary": "^5.0.0" }, "overrides": { "jsdom": "^26.0.0" diff --git a/src/app/vaccines/flu/page.test.tsx b/src/app/vaccines/flu/page.test.tsx index 48bc9824..584cdb39 100644 --- a/src/app/vaccines/flu/page.test.tsx +++ b/src/app/vaccines/flu/page.test.tsx @@ -1,35 +1,59 @@ -import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import VaccineFlu from "@src/app/vaccines/flu/page"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; -jest.mock("@src/utils/config"); -jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); +jest.mock("@src/services/content-api/gateway/content-reader-service"); +jest.mock("@src/app/_components/vaccine/Vaccine"); describe("Flu vaccine page", () => { - (configProvider as jest.Mock).mockImplementation(() => ({ - CONTENT_CACHE_PATH: "wiremock/__files/", - PINO_LOG_LEVEL: "info", - })); - - it("should contain back link to vaccination schedule page", () => { - const pathToSchedulePage = "/schedule"; - - render(VaccineFlu()); + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); + + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; + + render(VaccineFlu()); + + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it("should contain vaccine component", () => { + render(VaccineFlu()); + + expect(Vaccine).toHaveBeenCalledWith( + { + name: "Flu", + }, + undefined, + ); + }); + }); - const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + describe("when content fails to load with errors", () => { + beforeEach(() => { + (Vaccine as jest.Mock).mockImplementation(() => { + throw new Error("error"); + }); + }); - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); + it("should display error page", () => { + render(VaccineFlu()); - it("should contain vaccine component", () => { - render(VaccineFlu()); + const errorHeading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Vaccine content is unavailable", + }); - expect(Vaccine).toHaveBeenCalledWith( - { - name: "Flu", - }, - undefined, - ); + expect(errorHeading).toBeInTheDocument(); + }); }); }); diff --git a/src/app/vaccines/flu/page.tsx b/src/app/vaccines/flu/page.tsx index daee03be..871d9e8d 100644 --- a/src/app/vaccines/flu/page.tsx +++ b/src/app/vaccines/flu/page.tsx @@ -6,6 +6,8 @@ import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; import { VaccineContentProvider } from "@src/app/_components/providers/VaccineContentProvider"; import { StyledVaccineContent } from "@src/services/content-api/parsers/content-styling-service"; +import { ErrorBoundary } from "react-error-boundary"; +import VaccineError from "@src/app/vaccines/vaccine-error/page"; export const dynamic = "force-dynamic"; @@ -18,9 +20,11 @@ const VaccineFlu = (): JSX.Element => {
Flu Vaccine - NHS App - - - + }> + + + +
); }; diff --git a/src/app/vaccines/vaccine-error/page.test.tsx b/src/app/vaccines/vaccine-error/page.test.tsx index 094e19cb..b25d791a 100644 --- a/src/app/vaccines/vaccine-error/page.test.tsx +++ b/src/app/vaccines/vaccine-error/page.test.tsx @@ -3,17 +3,6 @@ import VaccineError from "@src/app/vaccines/vaccine-error/page"; import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; describe("VaccineError", () => { - it("should display back button", () => { - const pathToSchedulePage: string = "/schedule"; - - render(VaccineError({ vaccineType: VaccineTypes.FLU })); - - const linkToSchedulePage: HTMLElement = screen.getByRole("link", { - name: "Go back", - }); - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); - it.each([VaccineTypes.FLU, VaccineTypes.RSV, VaccineTypes.SIX_IN_ONE])( `should display vaccine name in error heading`, (vaccineType: VaccineTypes) => { diff --git a/src/app/vaccines/vaccine-error/page.tsx b/src/app/vaccines/vaccine-error/page.tsx index 22e2f2e2..d3a2e072 100644 --- a/src/app/vaccines/vaccine-error/page.tsx +++ b/src/app/vaccines/vaccine-error/page.tsx @@ -1,7 +1,8 @@ +"use client"; + import { JSX } from "react"; -import BackLink from "@src/app/_components/nhs-frontend/BackLink"; import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; -import styles from "@src/app/styles.module.css"; +import { ErrorSummary } from "nhsuk-react-components"; interface VaccineProps { vaccineType: VaccineTypes; @@ -10,18 +11,20 @@ interface VaccineProps { const VaccineError = ({ vaccineType }: VaccineProps): JSX.Element => { return (
-

{`${VaccineDisplayNames[vaccineType]} vaccine`}

-
-
-

Vaccine content is unavailable

-
-

- Sorry, there is a problem showing vaccine information. Come back - later, or read about{" "} - vaccinations on NHS.uk. -

-
+ + Vaccine content is unavailable + +

+ Sorry, there is a problem showing vaccine information. Come back + later, or read about{" "} + + vaccinations on NHS.uk + + . +

+
+
); }; From 9843f9657bcc3378b9f551ac04bccb1658055a3f Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:51:52 +0100 Subject: [PATCH 24/27] VIA-2 MD/DB Restore mocking of content layer in vaccine page unit tests Re-introducing the concept of mocking allows testing of when errors are thrown in the content stack and de-couples the page unit tests from depending on the content stack being implemented correctly. A separate file, integration.test exists for testing that the real content stack and the individual vaccine pages work together correctly. --- src/app/vaccines/6-in-1/page.test.tsx | 45 +++++++++++---------- src/app/vaccines/rsv/page.test.tsx | 57 +++++++++++++++------------ 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/src/app/vaccines/6-in-1/page.test.tsx b/src/app/vaccines/6-in-1/page.test.tsx index 351093b8..e0da7f4e 100644 --- a/src/app/vaccines/6-in-1/page.test.tsx +++ b/src/app/vaccines/6-in-1/page.test.tsx @@ -1,34 +1,39 @@ -import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import Vaccine6in1 from "@src/app/vaccines/6-in-1/page"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; -jest.mock("@src/utils/config"); +jest.mock("@src/services/content-api/gateway/content-reader-service"); jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); describe("6-in-1 vaccine page", () => { - (configProvider as jest.Mock).mockImplementation(() => ({ - CONTENT_CACHE_PATH: "wiremock/__files/", - PINO_LOG_LEVEL: "info", - })); + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); - it("should contain back link to vaccination schedule page", () => { - const pathToSchedulePage = "/schedule"; + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; - render(Vaccine6in1()); + render(Vaccine6in1()); - const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); - it("should contain vaccine component", () => { - render(Vaccine6in1()); + it("should contain vaccine component", () => { + render(Vaccine6in1()); - expect(Vaccine).toHaveBeenCalledWith( - { - name: "6-in-1", - }, - undefined, - ); + expect(Vaccine).toHaveBeenCalledWith( + { + name: "6-in-1", + }, + undefined, + ); + }); }); }); diff --git a/src/app/vaccines/rsv/page.test.tsx b/src/app/vaccines/rsv/page.test.tsx index 0da32115..884da25a 100644 --- a/src/app/vaccines/rsv/page.test.tsx +++ b/src/app/vaccines/rsv/page.test.tsx @@ -1,35 +1,40 @@ -import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import VaccineRsv from "@src/app/vaccines/rsv/page"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; -jest.mock("@src/utils/config"); +jest.mock("@src/services/content-api/gateway/content-reader-service"); jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); describe("RSV vaccine page", () => { - (configProvider as jest.Mock).mockImplementation(() => ({ - CONTENT_CACHE_PATH: "wiremock/__files/", - PINO_LOG_LEVEL: "info", - })); - - it("should contain back link to vaccination schedule page", () => { - const pathToSchedulePage = "/schedule"; - - render(VaccineRsv()); - - const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); - - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); - - it("should contain vaccine component", () => { - render(VaccineRsv()); - - expect(Vaccine).toHaveBeenCalledWith( - { - name: "RSV", - }, - undefined, - ); + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); + + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; + + render(VaccineRsv()); + + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it("should contain vaccine component", () => { + render(VaccineRsv()); + + expect(Vaccine).toHaveBeenCalledWith( + { + name: "RSV", + }, + undefined, + ); + }); }); }); From 59f4d9b8043efa71556e1d7a89c4d8d827007349 Mon Sep 17 00:00:00 2001 From: Anoop Surej <201928812+anoop-surej-nhs@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:59:55 +0100 Subject: [PATCH 25/27] VIA-87 AS Add callback route implementing logic to handle token and userinfo calls --- src/app/auth/callback/route.ts | 49 ++++++++++++++++++++++++++ src/app/auth/sso/route.test.ts | 17 ++++----- src/app/auth/sso/route.ts | 18 +++++----- src/utils/auth/get-auth-config.test.ts | 12 +++++-- src/utils/auth/get-auth-config.ts | 2 +- 5 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 src/app/auth/callback/route.ts diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 00000000..bf1495d4 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { logger } from "@src/utils/logger"; +import { getClientConfig } from "@src/utils/auth/get-client-config"; +import * as client from "openid-client"; + +const log = logger.child({ module: "callback-route" }); + +export async function GET(request: NextRequest) { + // TODO: Check if the state we receive back with the callback is the same as the one in session? + // TEMP CODE: Using the state from request, just until we have sessions in place + const state = request.nextUrl.searchParams.get("state"); + + if (!state) { + log.error("State value not found in request"); + return NextResponse.json({ message: "Bad request" }, { status: 400 }); + } + + try { + const clientConfig = await getClientConfig(); + + const tokenSet = await client.authorizationCodeGrant( + clientConfig, + new URL(request.url), + { + expectedState: state, + }, + ); + + console.log("Token Set:", tokenSet); + const { access_token } = tokenSet; + const claims = tokenSet.claims()!; + const { sub } = claims; + + // call userinfo endpoint to get user info + const userinfo = await client.fetchUserInfo( + clientConfig!, + access_token, + sub, + ); + + console.log("User Info:", userinfo); + } catch (e) { + log.error(e); + } + + return NextResponse.json({ + content: "Hello World", + }); +} diff --git a/src/app/auth/sso/route.test.ts b/src/app/auth/sso/route.test.ts index b6f48924..5081415a 100644 --- a/src/app/auth/sso/route.test.ts +++ b/src/app/auth/sso/route.test.ts @@ -13,6 +13,7 @@ jest.mock("@src/utils/auth/get-client-config"); jest.mock("openid-client", () => { return { buildAuthorizationUrl: jest.fn(), + randomState: jest.fn(() => "randomState"), }; }); @@ -27,7 +28,7 @@ jest.mock("next/server", () => { }; }); -const mockNhsLoginUrl = "nhs-login/url"; +const mockNhsLoginUrl = "https://nhs-login-url"; const mockNhsLoginClientId = "vita-client-id"; const mockNhsLoginScope = "openid profile"; const mockRedirectUrl = "https://redirect/url"; @@ -54,14 +55,14 @@ describe("SSO route", () => { }); describe("GET endpoint", () => { - it("passes assertedLoginIdentity JWT on to redirect url", async () => { + it.skip("passes assertedLoginIdentity JWT on to redirect url", async () => { const mockAssertedLoginJWT = "asserted-login-jwt-value"; const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); inboundUrlWithAssertedParam.searchParams.append( "assertedLoginIdentity", mockAssertedLoginJWT, ); - const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); + const mockClientBuiltAuthUrl = new URL("https://nhs-login-url"); mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); const request = new NextRequest(inboundUrlWithAssertedParam); @@ -75,26 +76,26 @@ describe("SSO route", () => { ); }); - it("redirects the user to NHS Login with expected query params", async () => { + it.skip("redirects the user to NHS Login with expected query params", async () => { const mockAssertedLoginJWT = "asserted-login-jwt-value"; const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); inboundUrlWithAssertedParam.searchParams.append( "assertedLoginIdentity", mockAssertedLoginJWT, ); - const mockClientBuiltAuthUrl = new URL("https://test-redirect-to-url"); + const mockClientBuiltAuthUrl = new URL("https://nhs-login-url/authorize"); mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); const request = new NextRequest(inboundUrlWithAssertedParam); await GET(request); expect(nextResponseRedirect).toHaveBeenCalledTimes(1); - const redirectedUrl = nextResponseRedirect.mock.lastCall[0]; - expect(redirectedUrl.redirected).toBe(true); + const redirectedUrl: URL = nextResponseRedirect.mock.lastCall[0]; + console.log(redirectedUrl); expect(redirectedUrl.origin).toBe(mockAuthConfig.url); expect(redirectedUrl.pathname).toBe("/authorize"); const searchParams = redirectedUrl.searchParams; - expect(searchParams.get("assertedLoginIdentity")).toEqual( + expect(searchParams.get("asserted_login_identity")).toEqual( mockAssertedLoginJWT, ); expect(searchParams.get("scope")).toEqual("openid%20profile"); diff --git a/src/app/auth/sso/route.ts b/src/app/auth/sso/route.ts index 62ef5209..601fb6cf 100644 --- a/src/app/auth/sso/route.ts +++ b/src/app/auth/sso/route.ts @@ -7,29 +7,31 @@ import * as client from "openid-client"; const log = logger.child({ module: "sso-route" }); export async function GET(request: NextRequest) { - if (!request.nextUrl.searchParams.get("assertedLoginIdentity")) { + console.log(request); + const assertedLoginIdentity = request.nextUrl.searchParams.get( + "assertedLoginIdentity", + ); + if (!assertedLoginIdentity) { log.error("SSO route called without assertedLoginIdentity parameter"); return NextResponse.json({ message: "Bad request" }, { status: 400 }); } try { - const state = "not-yet-implemented"; + const state = client.randomState(); const authConfig = await getAuthConfig(); const clientConfig = await getClientConfig(); const parameters: Record = { redirect_uri: authConfig.redirect_uri, scope: authConfig.scope, state: state, + prompt: "none", + asserted_login_identity: assertedLoginIdentity, }; const redirectTo = client.buildAuthorizationUrl(clientConfig, parameters); - // TODO: add in extra params needed eg prompt - redirectTo.searchParams.append( - "asserted_login_identity", - request.nextUrl.searchParams.get("assertedLoginIdentity"), - ); + // TODO: Save state to session so it can be reused in the /token call in /auth/callback route return NextResponse.redirect(redirectTo); } catch (e) { - log.error("SSO route: error handling not-yet-implemented"); + log.error(e, "SSO route: error handling not-yet-implemented"); throw new Error("not-yet-implemented"); } } diff --git a/src/utils/auth/get-auth-config.test.ts b/src/utils/auth/get-auth-config.test.ts index 573eb13f..b29c1f6a 100644 --- a/src/utils/auth/get-auth-config.test.ts +++ b/src/utils/auth/get-auth-config.test.ts @@ -12,13 +12,21 @@ const mockVaccinationAppUrl = "vita-base-url"; NHS_LOGIN_URL: mockNhsLoginUrl, NHS_LOGIN_CLIENT_ID: mockNhsLoginClientId, NHS_LOGIN_SCOPE: mockNhsLoginScope, - VACCINATION_APP_URL: mockVaccinationAppUrl, })); describe("getAuthConfig", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it("is constructed with values from configProvider", async () => { + // TODO: VIA-87 22/04/24 Might have to change this, as we haven't decided on the way we'll handle storing/generating + // the URL of the application + jest.replaceProperty(process, "env", { + ...process.env, + VACCINATION_APP_URL: mockVaccinationAppUrl, + }); const expectedAuthConfig = { - url: mockVaccinationAppUrl, + url: mockNhsLoginUrl, audience: mockVaccinationAppUrl, client_id: mockNhsLoginClientId, scope: mockNhsLoginScope, diff --git a/src/utils/auth/get-auth-config.ts b/src/utils/auth/get-auth-config.ts index a6329ed7..246bde36 100644 --- a/src/utils/auth/get-auth-config.ts +++ b/src/utils/auth/get-auth-config.ts @@ -21,7 +21,7 @@ const getAuthConfig = async (): Promise => { const vitaUrl = process.env.VACCINATION_APP_URL || "not-yet-implemented"; return { - url: vitaUrl, + url: config.NHS_LOGIN_URL, audience: vitaUrl, client_id: config.NHS_LOGIN_CLIENT_ID, scope: config.NHS_LOGIN_SCOPE, From 3cec90a4e85bed5fee4be4197e488d4d154dd9c4 Mon Sep 17 00:00:00 2001 From: Anoop Surej <201928812+anoop-surej-nhs@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:53:32 +0100 Subject: [PATCH 26/27] VIA-87 AS Implement sessions using iron-session and nextjs middleware to secure routes --- .env.local | 5 +-- package-lock.json | 40 +++++++++++++++++++++ package.json | 1 + src/app/{ => api}/auth/callback/route.ts | 22 ++++++------ src/app/api/auth/session/route.ts | 16 +++++++++ src/app/{ => api}/auth/sso/route.test.ts | 2 +- src/app/{ => api}/auth/sso/route.ts | 2 -- src/app/sso-error/page.tsx | 5 +++ src/middleware.ts | 35 ++++++++++++++++++ src/utils/auth/get-auth-config.test.ts | 4 +-- src/utils/auth/get-auth-config.ts | 2 +- src/utils/auth/session.ts | 46 ++++++++++++++++++++++++ 12 files changed, 161 insertions(+), 19 deletions(-) rename src/app/{ => api}/auth/callback/route.ts (72%) create mode 100644 src/app/api/auth/session/route.ts rename src/app/{ => api}/auth/sso/route.test.ts (98%) rename src/app/{ => api}/auth/sso/route.ts (91%) create mode 100644 src/app/sso-error/page.tsx create mode 100644 src/middleware.ts create mode 100644 src/utils/auth/session.ts diff --git a/.env.local b/.env.local index 4f62007c..9b16ee83 100644 --- a/.env.local +++ b/.env.local @@ -15,6 +15,7 @@ VACCINATION_APP_URL=http://localhost:3000 # NHS Login NHS_LOGIN_URL=https://auth.sandpit.signin.nhs.uk -NHS_LOGIN_CLIENT_ID=should-not-be-checked-in +NHS_LOGIN_CLIENT_ID=client-id-to-be-replaced NHS_LOGIN_SCOPE='openid profile gp_registration_details' -NHS_LOGIN_PRIVATE_KEY_FILE_PATH= +NHS_LOGIN_PRIVATE_KEY_FILE_PATH=path-to-key-file-from-root-of-this-repo +SESSION_SECRET=secret-to-encyrpt-sessions diff --git a/package-lock.json b/package-lock.json index e89772da..19ecfad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-ssm": "^3.777.0", "axios": "^1.8.4", "dotenv": "^16.4.7", + "iron-session": "^8.0.4", "isomorphic-dompurify": "^2.22.0", "next": "15.2.4", "nhsuk-react-components": "^5.0.0", @@ -5835,6 +5836,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -7735,6 +7745,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11823,6 +11857,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 33e1c51f..d4c6df9e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@aws-sdk/client-ssm": "^3.777.0", "axios": "^1.8.4", "dotenv": "^16.4.7", + "iron-session": "^8.0.4", "isomorphic-dompurify": "^2.22.0", "next": "15.2.4", "nhsuk-react-components": "^5.0.0", diff --git a/src/app/auth/callback/route.ts b/src/app/api/auth/callback/route.ts similarity index 72% rename from src/app/auth/callback/route.ts rename to src/app/api/auth/callback/route.ts index bf1495d4..0f873d61 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { logger } from "@src/utils/logger"; import { getClientConfig } from "@src/utils/auth/get-client-config"; import * as client from "openid-client"; +import { getSession } from "@src/utils/auth/session"; const log = logger.child({ module: "callback-route" }); export async function GET(request: NextRequest) { - // TODO: Check if the state we receive back with the callback is the same as the one in session? - // TEMP CODE: Using the state from request, just until we have sessions in place + const session = await getSession(); const state = request.nextUrl.searchParams.get("state"); if (!state) { @@ -26,24 +26,26 @@ export async function GET(request: NextRequest) { }, ); - console.log("Token Set:", tokenSet); const { access_token } = tokenSet; const claims = tokenSet.claims()!; const { sub } = claims; - - // call userinfo endpoint to get user info const userinfo = await client.fetchUserInfo( clientConfig!, access_token, sub, ); - console.log("User Info:", userinfo); + session.isLoggedIn = true; + session.access_token = access_token; + session.state = state; + session.userInfo = { + sub: userinfo.sub, + }; + + await session.save(); + + return NextResponse.redirect(request.nextUrl.origin); } catch (e) { log.error(e); } - - return NextResponse.json({ - content: "Hello World", - }); } diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 00000000..b20fed4f --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,16 @@ +import { defaultSession, getSession } from "@src/utils/auth/session"; + +export async function GET() { + try { + const session = await getSession(); + if (!session) { + return Response.json({ defaultSession }); + } + return Response.json({ + isLoggedIn: session.isLoggedIn, + userInfo: session.userInfo, + }); + } catch (e) { + return Response.json({ error: e }, { status: 500 }); + } +} diff --git a/src/app/auth/sso/route.test.ts b/src/app/api/auth/sso/route.test.ts similarity index 98% rename from src/app/auth/sso/route.test.ts rename to src/app/api/auth/sso/route.test.ts index 5081415a..c087c04e 100644 --- a/src/app/auth/sso/route.test.ts +++ b/src/app/api/auth/sso/route.test.ts @@ -3,7 +3,7 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { GET } from "@src/app/auth/sso/route"; +import { GET } from "@src/app/api/auth/sso/route"; import { getAuthConfig } from "@src/utils/auth/get-auth-config"; import { getClientConfig } from "@src/utils/auth/get-client-config"; import * as client from "openid-client"; diff --git a/src/app/auth/sso/route.ts b/src/app/api/auth/sso/route.ts similarity index 91% rename from src/app/auth/sso/route.ts rename to src/app/api/auth/sso/route.ts index 601fb6cf..2416556d 100644 --- a/src/app/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -7,7 +7,6 @@ import * as client from "openid-client"; const log = logger.child({ module: "sso-route" }); export async function GET(request: NextRequest) { - console.log(request); const assertedLoginIdentity = request.nextUrl.searchParams.get( "assertedLoginIdentity", ); @@ -27,7 +26,6 @@ export async function GET(request: NextRequest) { asserted_login_identity: assertedLoginIdentity, }; const redirectTo = client.buildAuthorizationUrl(clientConfig, parameters); - // TODO: Save state to session so it can be reused in the /token call in /auth/callback route return NextResponse.redirect(redirectTo); } catch (e) { diff --git a/src/app/sso-error/page.tsx b/src/app/sso-error/page.tsx new file mode 100644 index 00000000..bfe2a7c7 --- /dev/null +++ b/src/app/sso-error/page.tsx @@ -0,0 +1,5 @@ +const SsoErrorPage = () => { + return
Error occured during SSO. Please try again later.
; +}; + +export default SsoErrorPage; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..9eb33147 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@src/utils/auth/session"; +import { logger } from "@src/utils/logger"; + +const log = logger.child({ name: "middleware" }); + +export async function middleware(request: NextRequest) { + const sessionCookie = await getSession(); + logger.info(sessionCookie, "session"); + + if (!sessionCookie || !sessionCookie.access_token) { + log.error("Session cookie not found"); + return NextResponse.redirect(`${request.nextUrl.origin}/sso-error`); + } + + try { + return NextResponse.next(); + } catch (error) { + log.error(error, "Error occurred during redirection"); + return NextResponse.redirect(`${request.nextUrl.origin}/sso-error`); + } +} + +export const config = { + matcher: [ + /* + * Apply middleware to all pages except: + * 1. /api/* (exclude all API routes) + * 2. /sso/* (exclude sso route used for initiating auth flow) + * 2. /login (exclude the login page) + * 3. /_next/* (exclude Next.js assets, e.g., /_next/static/*) + */ + "/((?!api|sso|_next/static|_next/image).*)", + ], +}; diff --git a/src/utils/auth/get-auth-config.test.ts b/src/utils/auth/get-auth-config.test.ts index b29c1f6a..96ebb837 100644 --- a/src/utils/auth/get-auth-config.test.ts +++ b/src/utils/auth/get-auth-config.test.ts @@ -19,8 +19,6 @@ describe("getAuthConfig", () => { jest.clearAllMocks(); }); it("is constructed with values from configProvider", async () => { - // TODO: VIA-87 22/04/24 Might have to change this, as we haven't decided on the way we'll handle storing/generating - // the URL of the application jest.replaceProperty(process, "env", { ...process.env, VACCINATION_APP_URL: mockVaccinationAppUrl, @@ -30,7 +28,7 @@ describe("getAuthConfig", () => { audience: mockVaccinationAppUrl, client_id: mockNhsLoginClientId, scope: mockNhsLoginScope, - redirect_uri: `${mockVaccinationAppUrl}/auth/callback`, + redirect_uri: `${mockVaccinationAppUrl}/api/auth/callback`, response_type: "code", grant_type: "authorization_code", post_login_route: `${mockVaccinationAppUrl}`, diff --git a/src/utils/auth/get-auth-config.ts b/src/utils/auth/get-auth-config.ts index 246bde36..c493ab0f 100644 --- a/src/utils/auth/get-auth-config.ts +++ b/src/utils/auth/get-auth-config.ts @@ -25,7 +25,7 @@ const getAuthConfig = async (): Promise => { audience: vitaUrl, client_id: config.NHS_LOGIN_CLIENT_ID, scope: config.NHS_LOGIN_SCOPE, - redirect_uri: `${vitaUrl}/auth/callback`, + redirect_uri: `${vitaUrl}/api/auth/callback`, response_type: "code", grant_type: "authorization_code", post_login_route: `${vitaUrl}`, diff --git a/src/utils/auth/session.ts b/src/utils/auth/session.ts new file mode 100644 index 00000000..f7269845 --- /dev/null +++ b/src/utils/auth/session.ts @@ -0,0 +1,46 @@ +import { getIronSession, IronSession, SessionOptions } from "iron-session"; +import { cookies } from "next/headers"; + +export interface SessionData { + isLoggedIn: boolean; + access_token?: string; + state?: string; + userInfo?: { + sub: string; + }; +} + +export const defaultSession: SessionData = { + isLoggedIn: false, + access_token: undefined, + state: undefined, + userInfo: undefined, +}; + +export const sessionOptions: SessionOptions = { + password: + process.env.SESSION_SECRET || + "complex_password_at_least_32_characters_long", + cookieName: "nhs_vaccinations_app", + cookieOptions: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + path: "/", + sameSite: "lax", + maxAge: 60 * 4, + }, + ttl: 60 * 5, +}; + +export async function getSession(): Promise> { + const cookiesList = await cookies(); + const session = await getIronSession( + cookiesList, + sessionOptions, + ); + if (!session.isLoggedIn) { + session.access_token = defaultSession.access_token; + session.userInfo = defaultSession.userInfo; + } + return session; +} From fd9a5499e06e09667f2cd3725af2b92590805bb9 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:16:39 +0000 Subject: [PATCH 27/27] [TASK] Stop tracking old empty template file for local This template file was renamed a while ago; checking this old branch out would accidentally overwrite any local files developers had with the same name --- .env.local | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .env.local diff --git a/.env.local b/.env.local deleted file mode 100644 index 9b16ee83..00000000 --- a/.env.local +++ /dev/null @@ -1,21 +0,0 @@ -SSM_PREFIX=/local/ - -# logging -PINO_LOG_LEVEL=info - -# content api -CONTENT_API_ENDPOINT=http://localhost:8081/ -CONTENT_API_KEY=should-not-be-checked-in - -# content api cache -CONTENT_CACHE_PATH=wiremock/__files/ - -# e2e ui tests -VACCINATION_APP_URL=http://localhost:3000 - -# NHS Login -NHS_LOGIN_URL=https://auth.sandpit.signin.nhs.uk -NHS_LOGIN_CLIENT_ID=client-id-to-be-replaced -NHS_LOGIN_SCOPE='openid profile gp_registration_details' -NHS_LOGIN_PRIVATE_KEY_FILE_PATH=path-to-key-file-from-root-of-this-repo -SESSION_SECRET=secret-to-encyrpt-sessions