diff --git a/.env.example b/.env.example index 52a97abee2..b7f1713f3b 100644 --- a/.env.example +++ b/.env.example @@ -63,7 +63,6 @@ PRODUCTION_PG_URL_FOR_USER_GITHUB_ACTIONS=πŸ‘» SHAREDB_PORT=7003 # Integrations (local and pull request environments point to third party staging environments) -BOPS_API_ROOT_DOMAIN=πŸ‘» BOPS_API_TOKEN=πŸ‘» GOVUK_NOTIFY_API_KEY=πŸ‘» @@ -89,26 +88,16 @@ SUPPRESS_LOGS=true ## Lambeth GOV_UK_PAY_TOKEN_LAMBETH=πŸ‘» UNIFORM_CLIENT_LAMBETH=πŸ‘» -BOPS_SUBMISSION_URL_LAMBETH=https://lambeth.${BOPS_API_ROOT_DOMAIN} ## Southwark GOV_UK_PAY_TOKEN_SOUTHWARK=πŸ‘» UNIFORM_CLIENT_SOUTHWARK=πŸ‘» -BOPS_SUBMISSION_URL_SOUTHWARK=https://southwark.${BOPS_API_ROOT_DOMAIN} ## Buckinghamshire GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=πŸ‘» UNIFORM_CLIENT_AYLESBURY_VALE=πŸ‘» UNIFORM_CLIENT_CHILTERN=πŸ‘» UNIFORM_CLIENT_WYCOMBE=πŸ‘» -BOPS_SUBMISSION_URL_BUCKINGHAMSHIRE=https://buckinghamshire.${BOPS_API_ROOT_DOMAIN} - -## Camden -BOPS_SUBMISSION_URL_CAMDEN=https://camden.${BOPS_API_ROOT_DOMAIN} - -## Gloucester -BOPS_SUBMISSION_URL_GLOUCESTER=https://gloucester.${BOPS_API_ROOT_DOMAIN} ## End-to-end test team (borrows Lambeth's details) GOV_UK_PAY_TOKEN_E2E=πŸ‘» -BOPS_SUBMISSION_URL_E2E=https://lambeth.${BOPS_API_ROOT_DOMAIN} diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index 99745d4f3a..51ce761586 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -25,7 +25,6 @@ HASURA_GRAPHQL_URL=http://hasura:8080/v1/graphql HASURA_PLANX_API_KEY=πŸ‘» # Integrations -BOPS_API_ROOT_DOMAIN=πŸ‘» BOPS_API_TOKEN=πŸ‘» GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=πŸ‘» @@ -42,18 +41,9 @@ SLACK_WEBHOOK_URL=πŸ‘» # Local authority specific integrations ## Lambeth GOV_UK_PAY_TOKEN_LAMBETH=πŸ‘» -BOPS_SUBMISSION_URL_LAMBETH=https://lambeth.${BOPS_API_ROOT_DOMAIN} ## Southwark GOV_UK_PAY_TOKEN_SOUTHWARK=πŸ‘» -BOPS_SUBMISSION_URL_SOUTHWARK=https://southwark.${BOPS_API_ROOT_DOMAIN} ## Buckinghamshire -GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=πŸ‘» -BOPS_SUBMISSION_URL_BUCKINGHAMSHIRE=https://buckinghamshire.${BOPS_API_ROOT_DOMAIN} - -## Camden -BOPS_SUBMISSION_URL_CAMDEN=https://camden.${BOPS_API_ROOT_DOMAIN} - -## Gloucester -BOPS_SUBMISSION_URL_GLOUCESTER=https://gloucester.${BOPS_API_ROOT_DOMAIN} +GOV_UK_PAY_TOKEN_BUCKINGHAMSHIRE=πŸ‘» \ No newline at end of file diff --git a/api.planx.uk/lib/hasura/metadata/index.ts b/api.planx.uk/lib/hasura/metadata/index.ts index dcc8d11ffc..e9a7b7eac5 100644 --- a/api.planx.uk/lib/hasura/metadata/index.ts +++ b/api.planx.uk/lib/hasura/metadata/index.ts @@ -11,6 +11,7 @@ interface ScheduledEvent { export interface CombinedResponse { bops?: ScheduledEventResponse; + bops_v2?: ScheduledEventResponse; uniform?: ScheduledEventResponse; email?: ScheduledEventResponse; } diff --git a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts index 55b550cee6..be9b803d32 100644 --- a/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/createPaymentSendEvents.ts @@ -66,6 +66,17 @@ const createPaymentSendEvents = async ( comment: `bops_submission_${payload.sessionId}`, }); combinedResponse[Destination.BOPS] = bopsEvent; + + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + const bopsV2Event = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${teamSlug}`, + schedule_at: new Date(now.getTime() + 30 * 1000), + payload: eventPayload, + comment: `bops_v2_submission_${payload.sessionId}`, + }); + combinedResponse["bops_v2"] = bopsV2Event; + } } if (destinations.includes(Destination.Email)) { diff --git a/api.planx.uk/modules/send/bops/bops.test.ts b/api.planx.uk/modules/send/bops/bops.test.ts index 4f44b0bbe3..4a6d5051c2 100644 --- a/api.planx.uk/modules/send/bops/bops.test.ts +++ b/api.planx.uk/modules/send/bops/bops.test.ts @@ -3,6 +3,7 @@ import supertest from "supertest"; import { queryMock } from "../../../tests/graphqlQueryMock"; import app from "../../../server"; import { expectedPayload } from "../../../tests/mocks/bopsMocks"; +import { expectedPlanningPermissionPayload } from "../../../tests/mocks/digitalPlanningDataMocks"; jest.mock("../../saveAndReturn/service/utils", () => ({ markSessionAsSubmitted: jest.fn(), @@ -22,21 +23,28 @@ jest.mock("@opensystemslab/planx-core", () => { exportData: expectedPayload, redactedExportData: expectedPayload, }); + this.export.digitalPlanningDataPayload = () => + jest.fn().mockResolvedValue({ + exportData: expectedPlanningPermissionPayload, + }); } }, }; }); -const submissionURL = process.env.BOPS_SUBMISSION_URL_SOUTHWARK; - describe(`sending an application to BOPS`, () => { + const submissionURL = "https://test.bops-test.com"; + beforeEach(() => { queryMock.mockQuery({ name: "FindApplication", data: { bopsApplications: [], }, - variables: { session_id: "123" }, + variables: { + session_id: "123", + search_string: "%/api/v1/planning_applications", + }, }); queryMock.mockQuery({ @@ -46,6 +54,38 @@ describe(`sending an application to BOPS`, () => { insertBopsApplication: { id: 22 }, }, }); + + queryMock.mockQuery({ + name: "GetStagingBopsSubmissionURL", + data: { + teams: [ + { + integrations: { + bopsSubmissionURL: submissionURL, + }, + }, + ], + }, + variables: { + slug: "southwark", + }, + }); + + queryMock.mockQuery({ + name: "GetStagingBopsSubmissionURL", + data: { + teams: [ + { + integrations: { + bopsSubmissionURL: null, + }, + }, + ], + }, + variables: { + slug: "unsupported-team", + }, + }); }); it("proxies request and returns hasura id", async () => { @@ -102,7 +142,10 @@ describe(`sending an application to BOPS`, () => { { response: { message: "Application created", id: "bops_app_id" } }, ], }, - variables: { session_id: "previously_submitted_app" }, + variables: { + session_id: "previously_submitted_app", + search_string: "%/api/v1/planning_applications", + }, }); await supertest(app) @@ -119,3 +162,134 @@ describe(`sending an application to BOPS`, () => { }); }); }); + +describe(`sending an application to BOPS v2`, () => { + const submissionURL = "https://test.bops-test.com"; + + beforeEach(() => { + queryMock.mockQuery({ + name: "FindApplication", + data: { + bopsApplications: [], + }, + variables: { + session_id: "123", + search_string: "%/api/v2/planning_applications", + }, + }); + + queryMock.mockQuery({ + name: "CreateBopsApplication", + matchOnVariables: false, + data: { + insertBopsApplication: { id: 22 }, + }, + }); + + queryMock.mockQuery({ + name: "GetStagingBopsSubmissionURL", + data: { + teams: [ + { + integrations: { + bopsSubmissionURL: submissionURL, + }, + }, + ], + }, + variables: { + slug: "southwark", + }, + }); + + queryMock.mockQuery({ + name: "GetStagingBopsSubmissionURL", + data: { + teams: [ + { + integrations: { + bopsSubmissionURL: null, + }, + }, + ], + }, + variables: { + slug: "unsupported-team", + }, + }); + }); + + it("successfully proxies request and returns hasura id", async () => { + nock(`${submissionURL}/api/v2/planning_applications`).post("").reply(200, { + application: "0000123", + }); + + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "123" } }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + application: { id: 22, bopsResponse: { application: "0000123" } }, + }); + }); + }); + + it("requires auth", async () => { + await supertest(app) + .post("/bops-v2/southwark") + .send({ payload: { sessionId: "123" } }) + .expect(401); + }); + + it("throws an error if payload is missing", async () => { + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: null }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch(/Missing application/); + }); + }); + + it("throws an error if team is unsupported", async () => { + await supertest(app) + .post("/bops-v2/unsupported-team") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "123" } }) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch(/not enabled for this local authority/); + }); + }); + + it("does not re-send an application which has already been submitted", async () => { + queryMock.mockQuery({ + name: "FindApplication", + data: { + bopsApplications: [ + { response: { message: "Application created", id: "bops_app_id" } }, + ], + }, + variables: { + session_id: "previously_submitted_app", + search_string: "%/api/v2/planning_applications", + }, + }); + + await supertest(app) + .post("/bops-v2/southwark") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "previously_submitted_app" } }) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + sessionId: "previously_submitted_app", + bopsId: "bops_app_id", + message: "Skipping send, already successfully submitted", + }); + }); + }); +}); diff --git a/api.planx.uk/modules/send/bops/bops.ts b/api.planx.uk/modules/send/bops/bops.ts index 61014b50a3..22e4568a11 100644 --- a/api.planx.uk/modules/send/bops/bops.ts +++ b/api.planx.uk/modules/send/bops/bops.ts @@ -17,6 +17,7 @@ interface CreateBopsApplication { bopsId: string; }; } + const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { // `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key const { payload }: SendToBOPSRequest = req.body; @@ -30,7 +31,7 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { } // confirm that this session has not already been successfully submitted before proceeding - const submittedApp = await checkBOPSAuditTable(payload?.sessionId); + const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v1"); if (submittedApp?.message === "Application created") { return res.status(200).send({ sessionId: payload?.sessionId, @@ -43,8 +44,11 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { // a local or staging API instance should send to the BOPS staging endpoint // production should send to the BOPS production endpoint const localAuthority = req.params.localAuthority; - const bopsSubmissionURLEnvName = `BOPS_SUBMISSION_URL_${localAuthority.toUpperCase()}`; - const bopsSubmissionURL = process.env[bopsSubmissionURLEnvName]; + const env = process.env.NODE_ENV === "production" ? "production" : "staging"; + const bopsSubmissionURL = await $api.team.getBopsSubmissionURL( + localAuthority, + env, + ); const isSupported = Boolean(bopsSubmissionURL); if (!isSupported) { return next( @@ -144,6 +148,151 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { } }; +const sendToBOPSV2 = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + // `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key + const { payload }: SendToBOPSRequest = req.body; + if (!payload) { + return next( + new ServerError({ + status: 400, + message: `Missing application payload data to send to BOPS`, + }), + ); + } + + // confirm that this session has not already been successfully submitted before proceeding + const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v2"); + if (submittedApp?.message === "Application created") { + return res.status(200).send({ + sessionId: payload?.sessionId, + bopsId: submittedApp?.id, + message: `Skipping send, already successfully submitted`, + }); + } + + // confirm this local authority (aka team) is supported by BOPS before creating the proxy + // a local or staging API instance should send to the BOPS staging endpoint + // production should send to the BOPS production endpoint + const localAuthority = req.params.localAuthority; + const env = process.env.NODE_ENV === "production" ? "production" : "staging"; + const bopsSubmissionURL = await $api.team.getBopsSubmissionURL( + localAuthority, + env, + ); + const isSupported = Boolean(bopsSubmissionURL); + if (!isSupported) { + return next( + new ServerError({ + status: 400, + message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`, + }), + ); + } + + if (!isSupported) { + return next( + new ServerError({ + status: 400, + message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`, + }), + ); + } + const target = `${bopsSubmissionURL}/api/v2/planning_applications`; + const exportData = await $api.export.digitalPlanningDataPayload( + payload?.sessionId, + ); + + try { + const bopsResponse = await axios({ + method: "POST", + url: target, + adapter: "http", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.BOPS_API_TOKEN}`, + }, + data: exportData, + }) + .then(async (res: AxiosResponse<{ id: string }>) => { + // Mark session as submitted so that reminder and expiry emails are not triggered + markSessionAsSubmitted(payload?.sessionId); + + const applicationId = await $api.client.request( + gql` + mutation CreateBopsApplication( + $bops_id: String = "" + $destination_url: String! + $request: jsonb! + $req_headers: jsonb = {} + $response: jsonb = {} + $response_headers: jsonb = {} + $session_id: String! + ) { + insertBopsApplication: insert_bops_applications_one( + object: { + bops_id: $bops_id + destination_url: $destination_url + request: $request + req_headers: $req_headers + response: $response + response_headers: $response_headers + session_id: $session_id + } + ) { + id + bopsId: bops_id + } + } + `, + { + bops_id: res.data.id, + destination_url: target, + request: exportData, + response: res.data, + response_headers: res.headers, + session_id: payload?.sessionId, + }, + ); + + return { + application: { + ...applicationId.insertBopsApplication, + bopsResponse: res.data, + }, + }; + }) + .catch((error) => { + if (error.response) { + throw new Error( + `Sending to BOPS v2 failed (${localAuthority}):\n${JSON.stringify( + error.response.data, + null, + 2, + )}`, + ); + } else { + // re-throw other errors + throw new Error( + `Sending to BOPS v2 failed (${localAuthority}):\n${error}`, + ); + } + }); + res.send(bopsResponse); + } catch (err) { + next( + new ServerError({ + status: 500, + message: `Sending to BOPS v2 failed (${localAuthority})`, + cause: err, + }), + ); + } +}; + interface FindApplication { bopsApplications: { response: Record; @@ -155,12 +304,17 @@ interface FindApplication { */ async function checkBOPSAuditTable( sessionId: string, + version: "v1" | "v2", ): Promise> { + const searchString = `%/api/${version}/planning_applications`; const application = await $api.client.request( gql` - query FindApplication($session_id: String = "") { + query FindApplication($session_id: String = "", $search_string: String) { bopsApplications: bops_applications( - where: { session_id: { _eq: $session_id } } + where: { + session_id: { _eq: $session_id } + destination_url: { _like: $search_string } + } order_by: { created_at: desc } ) { response @@ -169,10 +323,11 @@ async function checkBOPSAuditTable( `, { session_id: sessionId, + search_string: searchString, }, ); return application?.bopsApplications[0]?.response; } -export { sendToBOPS }; +export { sendToBOPS, sendToBOPSV2 }; diff --git a/api.planx.uk/modules/send/createSendEvents/controller.ts b/api.planx.uk/modules/send/createSendEvents/controller.ts index 54f7af0ed7..6fa8b60808 100644 --- a/api.planx.uk/modules/send/createSendEvents/controller.ts +++ b/api.planx.uk/modules/send/createSendEvents/controller.ts @@ -35,6 +35,17 @@ const createSendEvents: CreateSendEventsController = async ( comment: `bops_submission_${sessionId}`, }); combinedResponse["bops"] = bopsEvent; + + const isProduction = process.env.APP_ENVIRONMENT === "production"; + if (!isProduction) { + const bopsV2Event = await createScheduledEvent({ + webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${bops.localAuthority}`, + schedule_at: new Date(now.getTime() + 45 * 1000), + payload: bops.body, + comment: `bops_v2_submission_${sessionId}`, + }); + combinedResponse["bops_v2"] = bopsV2Event; + } } if (uniform) { diff --git a/api.planx.uk/modules/send/docs.yaml b/api.planx.uk/modules/send/docs.yaml index 5796bfd6a3..dbf459d219 100644 --- a/api.planx.uk/modules/send/docs.yaml +++ b/api.planx.uk/modules/send/docs.yaml @@ -37,6 +37,23 @@ paths: application/json: schema: $ref: "#/components/schemas/SessionPayload" + /bops-v2/{localAuthority}: + post: + summary: Submits an application to the Back Office Planning System (BOPS) v2 + description: Submits an application to the Back Office Planning System (BOPS) using the ODP Schema payload (v2) + tags: + - send + parameters: + - $ref: "#/components/parameters/localAuthority" + security: + - hasuraAuth: [] + requestBody: + description: This endpoint is only called via Hasura's scheduled event webhook, so body is wrapped in a `payload` key + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SessionPayload" /email-submission/{localAuthority}: post: summary: Sends an application by email using GOV.UK Notify diff --git a/api.planx.uk/modules/send/routes.ts b/api.planx.uk/modules/send/routes.ts index 73f5974263..f17462070c 100644 --- a/api.planx.uk/modules/send/routes.ts +++ b/api.planx.uk/modules/send/routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { createSendEvents } from "./createSendEvents/controller"; import { useHasuraAuth } from "../auth/middleware"; -import { sendToBOPS } from "./bops/bops"; +import { sendToBOPS, sendToBOPSV2 } from "./bops/bops"; import { sendToUniform } from "./uniform/uniform"; import { sendToEmail } from "./email"; import { validate } from "../../shared/middleware/validate"; @@ -16,6 +16,7 @@ router.post( createSendEvents, ); router.post("/bops/:localAuthority", useHasuraAuth, sendToBOPS); +router.post("/bops-v2/:localAuthority", useHasuraAuth, sendToBOPSV2); router.post("/uniform/:localAuthority", useHasuraAuth, sendToUniform); router.post("/email-submission/:localAuthority", useHasuraAuth, sendToEmail); router.get("/download-application-files/:sessionId", downloadApplicationFiles); diff --git a/api.planx.uk/modules/webhooks/controller.ts b/api.planx.uk/modules/webhooks/controller.ts index edb43ac4eb..451af35473 100644 --- a/api.planx.uk/modules/webhooks/controller.ts +++ b/api.planx.uk/modules/webhooks/controller.ts @@ -14,6 +14,7 @@ import { } from "./service/paymentRequestEvents"; import { SanitiseApplicationData } from "./service/sanitiseApplicationData/types"; import { sanitiseApplicationData } from "./service/sanitiseApplicationData"; +import { IsCleanJSONBController } from "./service/validateInput/schema"; export const sendSlackNotificationController: SendSlackNotification = async ( _req, @@ -144,3 +145,26 @@ export const sanitiseApplicationDataController: SanitiseApplicationData = ); } }; + +export const isCleanJSONBController: IsCleanJSONBController = async ( + _req, + res, + next, +) => { + try { + const { isClean } = res.locals.parsedReq.body; + + return isClean + ? res.status(200).send() + : res.status(400).json({ + message: "Invalid HTML content", + }); + } catch (error) { + return next( + new ServerError({ + message: "Failed to validate application data", + cause: error, + }), + ); + } +}; diff --git a/api.planx.uk/modules/webhooks/docs.yaml b/api.planx.uk/modules/webhooks/docs.yaml index 309976e562..320afb9f27 100644 --- a/api.planx.uk/modules/webhooks/docs.yaml +++ b/api.planx.uk/modules/webhooks/docs.yaml @@ -126,6 +126,32 @@ components: email: type: string format: email + requests: + ValidateInputRequest: + description: | + Request generated by Hasura input validation + + Docs: [https://hasura.io/docs/latest/schema/postgres/input-validations/#request](https://hasura.io/docs/latest/schema/postgres/input-validations/#request) + type: object + required: + - body + properties: + body: + type: object + required: + - data + properties: + data: + type: object + required: + - input + properties: + input: + type: array + items: + type: object + additionalProperties: + type: string responses: SlackNotificationSuccessMessage: content: @@ -190,6 +216,20 @@ components: errorMessage: type: string required: false + InputValidationSuccess: + description: Successful response with no content + InputValidationFailure: + description: Input validation failed. Message will be returned to client. + content: + application/json: + example: + message: Invalid HTML content + schema: + type: object + properties: + message: + type: string + description: A message returned to the client paths: /webhooks/hasura/sendSlackNotification: post: @@ -311,3 +351,26 @@ paths: $ref: "#/components/responses/OperationResultSuccess" "500": $ref: "#/components/responses/OperationResultFailure" + /webhooks/hasura/validate-input/jsonb/clean-html: + post: + tags: ["webhooks"] + security: + - hasuraAuth: [] + summary: Validate if user-submitted JSONB contains safe HTML + description: | + Endpoint called by Hasura on INSERT or UPDATE to a table which containts user-submitted HTML content. + + Hasura runs a [Postgres Input Validation](https://hasura.io/docs/latest/schema/postgres/input-validations/) call against this endpoint which checks incoming content is clean and valid HTML. + + This endpoint does not sanitise and respond with clean data, it simply accepts or rejects the user-submitted changes. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/requests/ValidateInputRequest" + responses: + "200": + $ref: "#/components/responses/InputValidationSuccess" + "500": + $ref: "#/components/responses/InputValidationFailure" diff --git a/api.planx.uk/modules/webhooks/routes.ts b/api.planx.uk/modules/webhooks/routes.ts index 0be7edb4be..cde31db050 100644 --- a/api.planx.uk/modules/webhooks/routes.ts +++ b/api.planx.uk/modules/webhooks/routes.ts @@ -3,6 +3,7 @@ import { useHasuraAuth } from "../auth/middleware"; import { createPaymentSendEvents } from "../pay/service/inviteToPay/createPaymentSendEvents"; import { validate } from "../../shared/middleware/validate"; import { + isCleanJSONBController, createPaymentExpiryEventsController, createPaymentInvitationEventsController, createPaymentReminderEventsController, @@ -14,6 +15,7 @@ import { import { sendSlackNotificationSchema } from "./service/sendNotification/schema"; import { createPaymentEventSchema } from "./service/paymentRequestEvents/schema"; import { createSessionEventSchema } from "./service/lowcalSessionEvents/schema"; +import { isCleanJSONBSchema } from "./service/validateInput/schema"; const router = Router(); @@ -53,6 +55,12 @@ router.post( sanitiseApplicationDataController, ); +router.post( + "/webhooks/hasura/validate-input/jsonb/clean-html", + validate(isCleanJSONBSchema), + isCleanJSONBController, +); + // TODO: Convert to the new API module structure router.post( "/webhooks/hasura/create-payment-send-events", diff --git a/api.planx.uk/modules/webhooks/service/validateInput/schema.ts b/api.planx.uk/modules/webhooks/service/validateInput/schema.ts new file mode 100644 index 0000000000..f25250eb75 --- /dev/null +++ b/api.planx.uk/modules/webhooks/service/validateInput/schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { ValidatedRequestHandler } from "../../../../shared/middleware/validate"; +import { isCleanHTML, isObjectValid } from "./utils"; + +// Definition: https://hasura.io/docs/latest/schema/postgres/input-validations/#response +type HasuraValidateInputResponse = undefined | { message: string }; + +// Definition: https://hasura.io/docs/latest/schema/postgres/input-validations/#request +// Abstract base type that can be merged with specific schemas for data validation +const hasuraValidateInputRequestSchema = z.object({ + body: z.object({ + data: z.object({ + input: z.array(z.record(z.string(), z.unknown())), + }), + }), +}); + +type HasuraValidateInputRequest = z.infer< + typeof hasuraValidateInputRequestSchema +>; + +interface IsCleanJSONBRequest { + body: { + isClean: boolean; + }; +} + +type isCleanJSONBSchema = z.ZodType< + IsCleanJSONBRequest, + z.ZodTypeDef, + HasuraValidateInputRequest +>; + +/** + * Schema which iterates over values of a JSONB column + * Checks using DOMPurify to ensure that user-submitted HTML is clean + * Fails fast - will reject on first instance of unclean HTML + */ +export const isCleanJSONBSchema: isCleanJSONBSchema = + hasuraValidateInputRequestSchema.transform((original) => { + const isClean = original.body.data.input.every((input) => + isObjectValid(input, isCleanHTML), + ); + return { body: { isClean } }; + }); + +export type IsCleanJSONBController = ValidatedRequestHandler< + typeof isCleanJSONBSchema, + HasuraValidateInputResponse +>; diff --git a/api.planx.uk/modules/webhooks/service/validateInput/utils.test.ts b/api.planx.uk/modules/webhooks/service/validateInput/utils.test.ts new file mode 100644 index 0000000000..c3d58232ec --- /dev/null +++ b/api.planx.uk/modules/webhooks/service/validateInput/utils.test.ts @@ -0,0 +1,114 @@ +import { isCleanHTML, isObjectValid } from "./utils"; + +describe("isObjectValid", () => { + it("calls the callback for each child if validator returns true", () => { + const mockValidator = jest.fn().mockReturnValue(true); + + const testObject = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + }, + }, + }; + + isObjectValid(testObject, mockValidator); + + expect(mockValidator).toHaveBeenCalledTimes(5); + }); + + it("fails fast if any validator encounters any false values", () => { + const mockValidator = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + const testObject = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + }, + }, + }; + + isObjectValid(testObject, mockValidator); + + expect(mockValidator).toHaveBeenCalledTimes(2); + }); + + it("handles arrays correctly", () => { + const mockValidator = jest.fn().mockReturnValue(true); + + const testArray = [1, [2, 3], { a: 4 }]; + + isObjectValid(testArray, mockValidator); + + expect(mockValidator).toHaveBeenCalledTimes(4); + }); + + it("handles an object containing an array of objects", () => { + const mockValidator = jest.fn().mockReturnValue(true); + + const objectWithArrayOfObjects = { + a: 1, + b: { + c: [{ d: 2, e: { f: 3 } }, { g: 4 }, { h: [5, 6] }], + }, + }; + + isObjectValid(objectWithArrayOfObjects, mockValidator); + + expect(mockValidator).toHaveBeenCalledTimes(6); + }); + + it("handles empty objects and arrays", () => { + const mockValidator = jest.fn().mockReturnValue(true); + + const emptyObject = {}; + const emptyArray: unknown[] = []; + + isObjectValid(emptyObject, mockValidator); + isObjectValid(emptyArray, mockValidator); + + expect(mockValidator).not.toHaveBeenCalled(); + }); +}); + +describe("isCleanHTML() helper function", () => { + const dirtyHTML = [ + "", + "", + "

abc