refactor: extract server-side CRM contact update

This commit is contained in:
2026-11-06 09:58:02 +00:00
parent ea5e90943d
commit 71de8a2d14
7 changed files with 190 additions and 147 deletions
+29
View File
@@ -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;
};
+3 -27
View File
@@ -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, {
@@ -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);
}
+1 -1
View File
@@ -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,
@@ -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: () => {}
+148 -97
View File
@@ -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,
+1 -1
View File
@@ -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: () => {}