From 71de8a2d142e79656c00fb767685dbcb5ff88ad6 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 6 Nov 2026 09:58:02 +0000 Subject: [PATCH] refactor: extract server-side CRM contact update --- lib/server/updateCrmContact.js | 29 +++ pages/api/endpoint/updateaccount_api.js | 30 +-- .../file/createappealcompletemessage_api.js | 17 +- tests/phase15/service-behaviour.test.cjs | 2 +- .../endpoint-handler-contract.test.cjs | 12 +- tests/phase21/file-handler-contract.test.cjs | 245 +++++++++++------- tests/phase8/service-behaviour.test.cjs | 2 +- 7 files changed, 190 insertions(+), 147 deletions(-) create mode 100644 lib/server/updateCrmContact.js diff --git a/lib/server/updateCrmContact.js b/lib/server/updateCrmContact.js new file mode 100644 index 00000000..89a4308c --- /dev/null +++ b/lib/server/updateCrmContact.js @@ -0,0 +1,29 @@ +import axios from "axios"; +import { getToken } from "../../actions/core/token"; +import { hashAPIPath } from "../../actions/core/hash"; + +const WEBAPI_URL = + process.env.RELAY_ROOT || + "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; + +export const updateCrmContact = async (contactId, updatePayload) => { + const token = await getToken(); + const queryUrl = "contacts(" + contactId + ")"; + + const config = { + method: "patch", + url: WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), + headers: { + "OData-MaxVersion": "4.0", + "OData-Version": "4.0", + Accept: "application/json", + Prefer: 'odata.include-annotations="*",return=representation', + Authorization: "Bearer " + token.access_token, + "Content-Type": "application/json" + }, + data: JSON.stringify(updatePayload) + }; + + const response = await axios(config); + return response.data; +}; diff --git a/pages/api/endpoint/updateaccount_api.js b/pages/api/endpoint/updateaccount_api.js index 7f7c1f34..d1747e83 100644 --- a/pages/api/endpoint/updateaccount_api.js +++ b/pages/api/endpoint/updateaccount_api.js @@ -10,15 +10,9 @@ * description: Success */ -import axios from "axios"; import { consoleLogger } from "../../../actions/core/logger"; -import { getToken } from "../../../actions/core/token"; -import { hashAPIPath } from "../../../actions/core/hash"; import { respondError, respondSuccess } from "../middleware/apiResponse"; - -const WEBAPI_URL = - process.env.RELAY_ROOT || - "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; +import { updateCrmContact } from "../../../lib/server/updateCrmContact"; export default async function ApiProxy(req, res) { const contactId = req.query.contactId; @@ -40,26 +34,8 @@ export default async function ApiProxy(req, res) { } try { - const token = await getToken(); - const data = JSON.stringify(req.body); - const queryUrl = "contacts(" + contactId + ")"; - - const config = { - method: "patch", - url: WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), - headers: { - "OData-MaxVersion": "4.0", - "OData-Version": "4.0", - Accept: "application/json", - Prefer: 'odata.include-annotations="*",return=representation', - Authorization: "Bearer " + token.access_token, - "Content-Type": "application/json" - }, - data - }; - - const response = await axios(config); - return respondSuccess(res, response.data); + const data = await updateCrmContact(contactId, req.body); + return respondSuccess(res, data); } catch (error) { consoleLogger(error); return respondError(res, { diff --git a/pages/api/file/createappealcompletemessage_api.js b/pages/api/file/createappealcompletemessage_api.js index ebe4e59e..03401963 100644 --- a/pages/api/file/createappealcompletemessage_api.js +++ b/pages/api/file/createappealcompletemessage_api.js @@ -1,4 +1,4 @@ -import { updateAccount } from "../../../actions/services/accountService"; +import { updateCrmContact } from "../../../lib/server/updateCrmContact"; import { hashAPIPath } from "../../../actions/core/hash"; import { consoleLogger } from "../../../actions/core/logger"; import { @@ -59,10 +59,7 @@ ApiProxy.get(async (req, res) => { }); } - if ( - !isValidContainer(containerName) || - !isValidTempCaseRef(tempCaseRef) - ) { + if (!isValidContainer(containerName) || !isValidTempCaseRef(tempCaseRef)) { return respondError(res, { status: 400, code: "INVALID_REQUEST", @@ -158,13 +155,9 @@ ApiProxy.get(async (req, res) => { if (typeofinvolvement != 846040000) { void (async () => { try { - await updateAccount( - contactId[1], - { - pinswg_typeofinvolvement: 846040001 - }, - true - ); + await updateCrmContact(contactId[1], { + pinswg_typeofinvolvement: 846040001 + }); } catch (error) { consoleLogger(error); } diff --git a/tests/phase15/service-behaviour.test.cjs b/tests/phase15/service-behaviour.test.cjs index 207c55eb..9d5d596d 100644 --- a/tests/phase15/service-behaviour.test.cjs +++ b/tests/phase15/service-behaviour.test.cjs @@ -155,7 +155,7 @@ test("createappealcompletemessage_api rejects missing hash with 400", async () = createBlob: async () => ({}), getCaseBlob: async () => ({}), createCaseCompleteMessage: async () => ({}), - updateAccount: async () => ({}), + updateCrmContact: async () => ({}), consoleLogger: () => {}, _: { isEmpty: (value) => !value }, nextConnect, diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index b00212ce..1b34a7da 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -835,9 +835,7 @@ test("updateaccount returns CONTACT_ID_REQUIRED when contactId missing", async ( const mod = loadModule("pages/api/endpoint/updateaccount_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", - axios: async () => ({ data: { contactid: "c1" } }), + updateCrmContact: async () => ({ contactid: "c1" }), consoleLogger: () => {} }); @@ -853,9 +851,7 @@ test("updateaccount returns ACCOUNT_UPDATE_PAYLOAD_REQUIRED when body missing", const mod = loadModule("pages/api/endpoint/updateaccount_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", - axios: async () => ({ data: { contactid: "c1" } }), + updateCrmContact: async () => ({ contactid: "c1" }), consoleLogger: () => {} }); @@ -874,9 +870,7 @@ test("updateaccount catch path returns ACCOUNT_UPDATE_FAILED", async () => { const mod = loadModule("pages/api/endpoint/updateaccount_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", - axios: async () => { + updateCrmContact: async () => { throw new Error("relay failed"); }, consoleLogger: () => {} diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 3f3a18c3..a1ae3d79 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -149,7 +149,9 @@ const assertNoUploadWrites = (calls) => { }; test("upload rejects a missing NextAuth session before HMAC or Azure write", async () => { - const loaded = loadUploadAuthorization({ getServerSession: async () => null }); + const loaded = loadUploadAuthorization({ + getServerSession: async () => null + }); const res = createRes(); await loaded.mod.default.handler(uploadAuthorizationRequest(), res); @@ -906,9 +908,15 @@ for (const inputCase of [ { name: "missing container", body: { container: undefined } }, { name: "malformed container", body: { container: "Invalid_Container" } }, { name: "missing case identifier", body: { casefolderID: undefined } }, - { name: "case identifier path separator", body: { casefolderID: "TMP/CASE-1" } }, + { + name: "case identifier path separator", + body: { casefolderID: "TMP/CASE-1" } + }, { name: "case identifier traversal segment", body: { casefolderID: ".." } }, - { name: "case identifier control character", body: { casefolderID: "TMP\u0000CASE" } } + { + name: "case identifier control character", + body: { casefolderID: "TMP\u0000CASE" } + } ]) { test(`admin case deletion rejects ${inputCase.name} without deletion`, async () => { const loaded = loadAdminCaseDeletion(); @@ -917,7 +925,8 @@ for (const inputCase of [ request.body = { ...request.body, ...inputCase.body }; Object.keys(request.body).forEach((key) => { - if (typeof request.body[key] === "undefined") delete request.body[key]; + if (typeof request.body[key] === "undefined") + delete request.body[key]; }); await loaded.mod.default(request, res); @@ -938,10 +947,9 @@ test("valid administrator can delete a cross-container case prefix with the exis assert.deepStrictEqual(loaded.calls.deletion, [ [request.body.container, request.body.casefolderID] ]); - assert.deepStrictEqual( - JSON.parse(JSON.stringify(res.state.jsonBody)), - { data: loaded.payload } - ); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + data: loaded.payload + }); }); test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", async () => { @@ -1166,7 +1174,10 @@ test("valid administrative representation JSON edit preserves the existing mutat const updatedJson = JSON.parse(loaded.uploaded.content); assert.strictEqual(Object.hasOwn(updatedJson, "repComplete"), false); assert.strictEqual(updatedJson.containerID, loaded.sourceJson.containerID); - assert.strictEqual(updatedJson.ticketnumber, loaded.sourceJson.ticketnumber); + assert.strictEqual( + updatedJson.ticketnumber, + loaded.sourceJson.ticketnumber + ); assert.strictEqual( updatedJson.representationType, loaded.sourceJson.representationType @@ -1234,9 +1245,7 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant", getServerSession: async () => ({ user: { id: "container-1" } }), authOptions: () => ({}), hashAPIPath: (queryPath) => - queryPath.includes( - "/api/file/deleteblob?container=container-1" - ) + queryPath.includes("/api/file/deleteblob?container=container-1") ? "&hash=expected" : "&hash=other", respondError: respondErrorMock, @@ -1692,7 +1701,12 @@ const appealCompletionSigningRequest = (pathValue) => ({ }); for (const sessionCase of [ - { name: "missing session", session: null, status: 401, code: "UNAUTHENTICATED" }, + { + name: "missing session", + session: null, + status: 401, + code: "UNAUTHENTICATED" + }, { name: "session without user id", session: { user: {} } }, { name: "session with empty user id", session: { user: { id: "" } } }, { @@ -1705,7 +1719,9 @@ for (const sessionCase of [ } ]) { test(`gethash rejects appeal completion signing for ${sessionCase.name} before HMAC generation`, async () => { - const loaded = loadAppealCompletionSigning({ session: sessionCase.session }); + const loaded = loadAppealCompletionSigning({ + session: sessionCase.session + }); const res = createRes(); await loaded.mod.default.handler(appealCompletionSigningRequest(), res); @@ -2256,14 +2272,29 @@ test("createrepcompletemessage rejects unsupported methods before authorization for (const inputCase of [ { name: "missing container", query: { container: undefined } }, - { name: "invalid container type", query: { container: ["user-container"] } }, + { + name: "invalid container type", + query: { container: ["user-container"] } + }, { name: "malformed container", query: { container: "Invalid_Container" } }, { name: "missing case reference", query: { tempcaseref: undefined } }, - { name: "unsafe case-reference path separator", query: { tempcaseref: "CAS/123" } }, - { name: "unsafe case-reference traversal segment", query: { tempcaseref: ".." } }, + { + name: "unsafe case-reference path separator", + query: { tempcaseref: "CAS/123" } + }, + { + name: "unsafe case-reference traversal segment", + query: { tempcaseref: ".." } + }, { name: "missing representation identifier", query: { repid: undefined } }, - { name: "unsafe representation path separator", query: { repid: "rep\\file" } }, - { name: "unsafe representation control character", query: { repid: "rep\u0000file" } } + { + name: "unsafe representation path separator", + query: { repid: "rep\\file" } + }, + { + name: "unsafe representation control character", + query: { repid: "rep\u0000file" } + } ]) { test(`createrepcompletemessage rejects ${inputCase.name} before authorization work`, async () => { const loaded = loadOrdinaryRepCompletion(); @@ -2272,7 +2303,8 @@ for (const inputCase of [ request.query = { ...request.query, ...inputCase.query }; Object.keys(request.query).forEach((key) => { - if (typeof request.query[key] === "undefined") delete request.query[key]; + if (typeof request.query[key] === "undefined") + delete request.query[key]; }); await loaded.mod.default(request, res); @@ -2416,7 +2448,13 @@ test("createrepcompletemessage dependency failure returns CREATE_REP_COMPLETE_ME }); const loadAdminRepCompletion = (overrides = {}) => { - const calls = { noStore: 0, readSession: 0, validate: 0, origin: 0, queue: [] }; + const calls = { + noStore: 0, + readSession: 0, + validate: 0, + origin: 0, + queue: [] + }; const payload = { messageId: "admin-message" }; const mod = loadModule("pages/api/admin/createrepcompletemessage.js", { respondError: respondErrorMock, @@ -2495,7 +2533,10 @@ for (const sessionCase of [ } for (const originCase of [ - { name: "missing Origin", headers: { cookie: "pedw_admin_session=admin-token" } }, + { + name: "missing Origin", + headers: { cookie: "pedw_admin_session=admin-token" } + }, { name: "cross-origin request", headers: { @@ -2505,7 +2546,9 @@ for (const originCase of [ } ]) { test(`admin representation completion rejects ${originCase.name}`, async () => { - const loaded = loadAdminRepCompletion({ hasTrustedSameOrigin: () => false }); + const loaded = loadAdminRepCompletion({ + hasTrustedSameOrigin: () => false + }); const res = createRes(); await loaded.mod.default( @@ -2533,7 +2576,8 @@ for (const inputCase of [ request.body = { ...request.body, ...inputCase.body }; Object.keys(request.body).forEach((key) => { - if (typeof request.body[key] === "undefined") delete request.body[key]; + if (typeof request.body[key] === "undefined") + delete request.body[key]; }); await loaded.mod.default(request, res); @@ -3051,7 +3095,9 @@ for (const adminSessionCase of [ for (const originCase of ["missing Origin", "cross-origin request"]) { test(`admin getrepsblobproxy rejects ${originCase} before proxy work`, async () => { - const loaded = loadAdminRepsProxy({ hasTrustedSameOrigin: () => false }); + const loaded = loadAdminRepsProxy({ + hasTrustedSameOrigin: () => false + }); const req = adminRepsRequest(); if (originCase === "missing Origin") delete req.headers.origin; if (originCase === "cross-origin request") { @@ -3563,53 +3609,59 @@ const loadAppealCompletionDirect = ({ session, ...overrides } = {}) => { "customerid_contact@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)" }; - const mod = loadModule("pages/api/file/createappealcompletemessage_api.js", { - nextConnect: createNextConnectMock(), - middleware: () => {}, - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, - authOptions: (req, res) => { - calls.options.push([req, res]); - return options; - }, - getServerSession: async (...args) => { - calls.session.push(args); - return resolvedSession; - }, - hashAPIPath: (queryPath) => { - calls.hash.push(queryPath); - return "&hash=expected"; - }, - getProgressBlobs: async (...args) => { - calls.progress.push(args); - return { path: "TMP-CASE-1/progress.json" }; - }, - downloadProgressFile: async (...args) => { - calls.download.push(args); - return args[1].endsWith("_case.json") - ? { ...caseObject } - : { ...progress }; - }, - createBlob: async (...args) => { - calls.createBlob.push(args); - return {}; - }, - getCaseBlob: async (...args) => { - calls.caseBlob.push(args); - return {}; - }, - updateAccount: async (...args) => { - calls.account.push(args); - return {}; - }, - createCaseCompleteMessage: async (...args) => { - calls.queue.push(args); - return {}; - }, - consoleLogger: () => {}, - _: { isEmpty: (value) => value === undefined || value === null || value === "" }, - ...overrides - }); + const mod = loadModule( + "pages/api/file/createappealcompletemessage_api.js", + { + nextConnect: createNextConnectMock(), + middleware: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return resolvedSession; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=expected"; + }, + getProgressBlobs: async (...args) => { + calls.progress.push(args); + return { path: "TMP-CASE-1/progress.json" }; + }, + downloadProgressFile: async (...args) => { + calls.download.push(args); + return args[1].endsWith("_case.json") + ? { ...caseObject } + : { ...progress }; + }, + createBlob: async (...args) => { + calls.createBlob.push(args); + return {}; + }, + getCaseBlob: async (...args) => { + calls.caseBlob.push(args); + return {}; + }, + updateCrmContact: async (...args) => { + calls.account.push(args); + return {}; + }, + createCaseCompleteMessage: async (...args) => { + calls.queue.push(args); + return {}; + }, + consoleLogger: () => {}, + _: { + isEmpty: (value) => + value === undefined || value === null || value === "" + }, + ...overrides + } + ); return { mod, calls, options }; }; @@ -3647,7 +3699,9 @@ for (const sessionCase of [ } ]) { test(`createappealcompletemessage_api blocks direct bypass with ${sessionCase.name} before HMAC or side effects`, async () => { - const loaded = loadAppealCompletionDirect({ session: sessionCase.session }); + const loaded = loadAppealCompletionDirect({ + session: sessionCase.session + }); const res = createRes(); await loaded.mod.default.handler(appealCompletionDirectRequest(), res); @@ -3799,11 +3853,7 @@ test("createappealcompletemessage_api preserves owner payloads and downstream co ]); assert.deepStrictEqual(loaded.calls.download, [ ["owner-container", "TMP-CASE-1/progress.json", "TMP-CASE-1"], - [ - "owner-container", - "TMP-CASE-1/TMP-CASE-1_case.json", - "TMP-CASE-1" - ] + ["owner-container", "TMP-CASE-1/TMP-CASE-1_case.json", "TMP-CASE-1"] ]); const savedProgress = JSON.parse(loaded.calls.createBlob[0][0]); assert.strictEqual(savedProgress.appealComplete, "TMP-CASE-1"); @@ -3813,16 +3863,12 @@ test("createappealcompletemessage_api preserves owner payloads and downstream co "TMP-CASE-1" ]); assert.strictEqual(loaded.calls.caseBlob.length, 1); - assert.deepStrictEqual( - JSON.parse(JSON.stringify(loaded.calls.account)), + assert.deepStrictEqual(JSON.parse(JSON.stringify(loaded.calls.account)), [ [ - [ - "00000000-0000-0000-0000-000000000001", - { pinswg_typeofinvolvement: 846040001 }, - true - ] + "00000000-0000-0000-0000-000000000001", + { pinswg_typeofinvolvement: 846040001 } ] - ); + ]); assert.deepStrictEqual(loaded.calls.queue, [ ["owner-container", "TMP-CASE-1"] ]); @@ -3905,7 +3951,7 @@ test("createappealcompletemessage_api accepts encoded hash variant and returns s createBlob: async () => ({}), getCaseBlob: async () => ({}), createCaseCompleteMessage: async () => ({}), - updateAccount: async () => ({}), + updateCrmContact: async () => ({}), consoleLogger: () => {}, _: { isEmpty: (v) => v === undefined || v === null || v === "" } } @@ -4987,7 +5033,9 @@ for (const endpoint of [ test(`${endpoint.name} rejects another user's container before privileged work`, async () => { const loaded = endpoint.load({ - getServerSession: async () => ({ user: { id: "different-container" } }) + getServerSession: async () => ({ + user: { id: "different-container" } + }) }); const res = createRes(); @@ -5000,7 +5048,9 @@ for (const endpoint of [ test(`${endpoint.name} ignores a client-provided user identity`, async () => { const loaded = endpoint.load({ - getServerSession: async () => ({ user: { id: "different-container" } }) + getServerSession: async () => ({ + user: { id: "different-container" } + }) }); const req = endpoint.request(); req.body.userID = PDF_OWNER_CONTAINER; @@ -5146,7 +5196,9 @@ for (const endpoint of [ test(`${endpoint.name} rejects a valid HMAC for an unauthorised container`, async () => { const loaded = endpoint.load({ - getServerSession: async () => ({ user: { id: "different-container" } }) + getServerSession: async () => ({ + user: { id: "different-container" } + }) }); const res = createRes(); await loaded.mod.default(endpoint.request(), res); @@ -5498,10 +5550,7 @@ test("createcase_api creates both draft JSON blobs in the authenticated owner's assert.strictEqual(loaded.calls.caseBlob.length, 1); assert.strictEqual(loaded.calls.appealBlob.length, 1); assert.strictEqual(loaded.calls.caseBlob[0][0], CASE_OWNER_CONTAINER); - assert.strictEqual( - loaded.calls.caseBlob[0][1], - GENERATED_CASE_REFERENCE - ); + assert.strictEqual(loaded.calls.caseBlob[0][1], GENERATED_CASE_REFERENCE); assert.strictEqual(loaded.calls.appealBlob[0][1], CASE_OWNER_CONTAINER); assert.deepStrictEqual( JSON.parse(JSON.stringify(loaded.calls.caseBlob[0][2])), @@ -5510,10 +5559,12 @@ test("createcase_api creates both draft JSON blobs in the authenticated owner's assert.deepStrictEqual(JSON.parse(loaded.calls.appealBlob[0][0]), { pinswg_name: GENERATED_CASE_REFERENCE, pinswg_appealcasetype: "846040000", - _pinswg_associatedlpa_value: - "00000000-0000-4000-8000-000000000002" + _pinswg_associatedlpa_value: "00000000-0000-4000-8000-000000000002" }); - assert.strictEqual(res.state.jsonBody.ticketnumber, GENERATED_CASE_REFERENCE); + assert.strictEqual( + res.state.jsonBody.ticketnumber, + GENERATED_CASE_REFERENCE + ); assert.strictEqual(res.state.jsonBody.title, GENERATED_CASE_REFERENCE); assert.strictEqual( res.state.jsonBody.incidentid, diff --git a/tests/phase8/service-behaviour.test.cjs b/tests/phase8/service-behaviour.test.cjs index eb677911..77e109bb 100644 --- a/tests/phase8/service-behaviour.test.cjs +++ b/tests/phase8/service-behaviour.test.cjs @@ -140,7 +140,7 @@ test("createappealcompletemessage_api rejects invalid hash with 400", async () = createBlob: async () => {}, getCaseBlob: async () => {}, createCaseCompleteMessage: () => {}, - updateAccount: async () => {}, + updateCrmContact: async () => {}, _: { isEmpty: (value) => !value }, nextConnect, middleware: () => {}