diff --git a/api.planx.uk/modules/send/bops/bops.test.ts b/api.planx.uk/modules/send/bops/bops.test.ts index e8a5b93675..9c54f3d09c 100644 --- a/api.planx.uk/modules/send/bops/bops.test.ts +++ b/api.planx.uk/modules/send/bops/bops.test.ts @@ -61,7 +61,8 @@ describe(`sending an application to BOPS`, () => { teams: [ { integrations: { - bopsSubmissionURL: submissionURL, + submissionURL, + secret: null, }, }, ], @@ -78,6 +79,7 @@ describe(`sending an application to BOPS`, () => { { integrations: { submissionURL: null, + secret: null, }, }, ], @@ -128,7 +130,7 @@ describe(`sending an application to BOPS`, () => { .post("/bops/unsupported-team") .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) .send({ payload: { sessionId: "123" } }) - .expect(400) + .expect(500) .then((res) => { expect(res.body.error).toMatch(/not enabled for this local authority/); }); @@ -193,6 +195,9 @@ describe(`sending an application to BOPS v2`, () => { { integrations: { submissionURL: submissionURL, + // Decodes to "abc123" + secret: + "ccd96ddbcf94af4899a1fe9c88752547:e913b3b604f0610ee57abb822e6cc6fd", }, }, ], @@ -205,13 +210,7 @@ describe(`sending an application to BOPS v2`, () => { queryMock.mockQuery({ name: "GetStagingBopsSubmissionDetails", data: { - teams: [ - { - integrations: { - submissionURL: null, - }, - }, - ], + teams: [], }, variables: { slug: "unsupported-team", @@ -259,7 +258,7 @@ describe(`sending an application to BOPS v2`, () => { .post("/bops-v2/unsupported-team") .set({ Authorization: process.env.HASURA_PLANX_API_KEY! }) .send({ payload: { sessionId: "123" } }) - .expect(400) + .expect(500) .then((res) => { expect(res.body.error).toMatch(/not enabled for this local authority/); }); diff --git a/api.planx.uk/modules/send/bops/bops.ts b/api.planx.uk/modules/send/bops/bops.ts index a89c982065..4787fafe4d 100644 --- a/api.planx.uk/modules/send/bops/bops.ts +++ b/api.planx.uk/modules/send/bops/bops.ts @@ -1,4 +1,4 @@ -import axios, { AxiosResponse } from "axios"; +import { BOPSClient } from "./bopsClient"; import { markSessionAsSubmitted } from "../../saveAndReturn/service/utils"; import { NextFunction, Request, Response } from "express"; import { gql } from "graphql-request"; @@ -40,106 +40,63 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => { }); } - // 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.APP_ENVIRONMENT === "production" ? "production" : "staging"; - const details = await $api.team.getBopsSubmissionDetails(localAuthority, env); - if (!details?.submissionURL) { - return next( - new ServerError({ - status: 400, - message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`, - }), - ); - } - const target = `${details.submissionURL}/api/v1/planning_applications`; - const exportData = await $api.export.bopsPayload(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 - } + const bopsClient = await BOPSClient.create(localAuthority); + const response = await bopsClient.postV1(payload.sessionId); + + // Mark session as submitted so that reminder and expiry emails are not triggered + await 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 } - `, - { - 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 failed (${localAuthority}):\n${JSON.stringify( - error.response.data, - null, - 2, - )}`, - ); - } else { - // re-throw other errors - throw new Error( - `Sending to BOPS failed (${localAuthority}):\n${error}`, - ); + ) { + id + bopsId: bops_id + } } - }); - res.send(bopsResponse); - } catch (err) { - next( + `, + { + bops_id: response.data.id, + destination_url: response.request.url, + request: response.request.data, + response: response.data, + response_headers: response.headers, + session_id: payload?.sessionId, + }, + ); + + res.send({ + application: { + ...applicationId.insertBopsApplication, + bopsResponse: response.data, + }, + }); + } catch (error) { + return next( new ServerError({ status: 500, - message: `Sending to BOPS failed (${localAuthority})`, - cause: err, + message: `Sending to BOPS failed (${localAuthority}). ${error}`, + cause: error, }), ); } @@ -171,104 +128,58 @@ const sendToBOPSV2 = async ( }); } - // 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.APP_ENVIRONMENT === "production" ? "production" : "staging"; - const details = await $api.team.getBopsSubmissionDetails(localAuthority, env); - if (!details?.submissionURL) { - return next( - new ServerError({ - status: 400, - message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`, - }), - ); - } - const target = `${details?.submissionURL}/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 - } + const bopsClient = await BOPSClient.create(localAuthority); + const response = await bopsClient.postV2(payload.sessionId); + + // Mark session as submitted so that reminder and expiry emails are not triggered + await 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 } - `, - { - 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, payload?.sessionId] - .filter(Boolean) - .join(" - ")}):\n${JSON.stringify(error.response.data, null, 2)}`, - ); - } else { - // re-throw other errors - throw new Error( - `Sending to BOPS v2 failed (${[localAuthority, payload?.sessionId] - .filter(Boolean) - .join(" - ")}):\n${error}`, - ); + ) { + id + bopsId: bops_id + } } - }); - res.send(bopsResponse); - } catch (err) { - next( + `, + { + bops_id: response.data.id, + destination_url: response.request.url, + request: response.request.data, + response: response.data, + response_headers: response.headers, + session_id: payload?.sessionId, + }, + ); + res.send({ + application: { + ...applicationId.insertBopsApplication, + bopsResponse: response.data, + }, + }); + } catch (error) { + return next( new ServerError({ status: 500, message: `Sending to BOPS v2 failed (${[ @@ -276,8 +187,9 @@ const sendToBOPSV2 = async ( payload?.sessionId, ] .filter(Boolean) - .join(" - ")})`, - cause: err, + .join(" - ")}). + ${error}`, + cause: error, }), ); } diff --git a/api.planx.uk/modules/send/bops/bopsClient.ts b/api.planx.uk/modules/send/bops/bopsClient.ts new file mode 100644 index 0000000000..db4b4d2a9c --- /dev/null +++ b/api.planx.uk/modules/send/bops/bopsClient.ts @@ -0,0 +1,112 @@ +import axios, { AxiosInstance, isAxiosError, AxiosResponse } from "axios"; +import { $api } from "../../../client"; +import * as crypto from "crypto"; + +interface BOPSResponse { + id: string; + message: string; +} + +export class BOPSClient { + private teamSlug: string; + private axiosInstance: AxiosInstance; + private submissionURL: string; + + private constructor(teamSlug: string) { + // Defaults + this.teamSlug = teamSlug; + this.axiosInstance = axios.create({}); + this.submissionURL = ""; + } + + public static async create(teamSlug: string): Promise { + const client = new BOPSClient(teamSlug); + await client.initialise(); + return client; + } + + private async initialise() { + try { + const env = + process.env.APP_ENVIRONMENT === "production" ? "production" : "staging"; + const { submissionURL, secret } = + await $api.team.getBopsSubmissionDetails(this.teamSlug, env); + this.submissionURL = submissionURL; + this.initAxiosInstance(secret); + } catch (error) { + throw new Error( + `BOPS not enabled for this local authority: ${this.teamSlug}`, + ); + } + } + + private async initAxiosInstance(secret: string | null) { + const authToken = secret + ? this.decrypt(secret) + : process.env.BOPS_API_TOKEN!; + + this.axiosInstance = axios.create({ + baseURL: this.submissionURL + "/api/", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + }); + } + + private handleError(error: unknown): never { + if (isAxiosError(error) && error.response) { + throw new Error( + `Sending to BOPS failed (${this.teamSlug}):\n${JSON.stringify( + error.response.data, + null, + 2, + )}`, + ); + } + + throw new Error(`Sending to BOPS failed (${this.teamSlug}):\n${error}`); + } + + async postV1(sessionId: string): Promise> { + await this.initialise(); + const exportData = await $api.export.bopsPayload(sessionId); + try { + const response = await this.axiosInstance.post( + "v1/planning_applications", + JSON.stringify(exportData), + ); + return response; + } catch (error) { + this.handleError(error); + } + } + + async postV2(sessionId: string): Promise> { + this.initialise(); + const exportData = await $api.export.bopsPayload(sessionId); + try { + const response = await this.axiosInstance.post( + "v2/planning_applications", + JSON.stringify(exportData), + ); + return response; + } catch (error) { + this.handleError(error); + } + } + + private decrypt(secret: string) { + const key = process.env.ENCRYPTION_KEY!; + const [encryptedToken, iv] = secret.split(":"); + const decipher = crypto.createDecipheriv( + "AES-256-CBC", + Buffer.from(key, "utf-8"), + Buffer.from(iv, "hex"), + ); + let decryptedToken = decipher.update(encryptedToken, "hex", "utf-8"); + decryptedToken += decipher.final("utf-8"); + + return decryptedToken; + } +}