diff --git a/pages/api/endpoint/deletewatchedcases_api.js b/pages/api/endpoint/deletewatchedcases_api.js index 5c59ecff..af4aad28 100644 --- a/pages/api/endpoint/deletewatchedcases_api.js +++ b/pages/api/endpoint/deletewatchedcases_api.js @@ -11,32 +11,143 @@ */ import axios from "axios"; +import { getServerSession } from "next-auth/next"; +import { azureHeaders } from "../../../actions/core/headers"; +import { escapeODataString } from "../../../actions/core/guards"; import { consoleLogger } from "../../../actions/core/logger"; import { getToken } from "../../../actions/core/token"; import { hashAPIPath } from "../../../actions/core/hash"; +import { authOptions } from "../auth/[...nextauth]"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { relayGetData } from "../middleware/relayForwarding"; const WEBAPI_URL = process.env.RELAY_ROOT || "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; +const GUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const normaliseGuid = (value) => value.trim().toLowerCase(); + export default async function ApiProxy(req, res) { const watchedCaseID = req.query.watchedCaseID; if ( typeof watchedCaseID !== "string" || - watchedCaseID.trim().length === 0 + !GUID_PATTERN.test(watchedCaseID) ) { return respondError(res, { status: 400, - code: "WATCHED_CASE_ID_REQUIRED", - message: "watchedCaseID is required" + code: "INVALID_WATCHED_CASE_ID", + message: "watchedCaseID must be a valid GUID" + }); + } + + if (req.method !== "DELETE") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Only DELETE is supported" + }); + } + + const session = await getServerSession(req, res, authOptions(req, res)); + const authenticatedEmail = session?.user?.email; + + if ( + typeof authenticatedEmail !== "string" || + authenticatedEmail.trim().length === 0 + ) { + return respondError(res, { + status: 401, + code: "UNAUTHENTICATED", + message: "Authentication required" }); } try { const token = await getToken(); - const queryUrl = "pinswg_watchlists(" + watchedCaseID + ")"; + const requestOptionsBuilder = (accessToken) => azureHeaders(accessToken); + const escapedEmail = escapeODataString(authenticatedEmail); + const contactQueryUrl = + "contacts?$filter=emailaddress1 eq '" + + escapedEmail + + "' and statuscode eq 1&$select=contactid"; + const contactResponse = await relayGetData({ + queryUrl: contactQueryUrl, + accessToken: token.access_token, + requestOptionsBuilder + }); + const contacts = contactResponse?.data?.value; + + if (!Array.isArray(contacts)) { + return respondError(res, { + status: 500, + code: "CONTACT_LOOKUP_FAILED", + message: "Unable to verify portal contact" + }); + } + + if ( + contacts.length !== 1 || + typeof contacts[0]?.contactid !== "string" || + !GUID_PATTERN.test(contacts[0].contactid.trim()) + ) { + return respondError(res, { + status: 403, + code: "CONTACT_IDENTITY_NOT_RESOLVED", + message: "Request not permitted" + }); + } + + const normalisedWatchedCaseID = normaliseGuid(watchedCaseID); + const watchlistQueryUrl = + "pinswg_watchlists(" + + normalisedWatchedCaseID + + ")?$select=pinswg_watchlistid,_pinswg_contact_value"; + let watchlistResponse; + + try { + watchlistResponse = await relayGetData({ + queryUrl: watchlistQueryUrl, + accessToken: token.access_token, + requestOptionsBuilder + }); + } catch (error) { + if (error?.response?.status === 404) { + return respondError(res, { + status: 404, + code: "WATCHED_CASE_NOT_FOUND", + message: "Watched case not found" + }); + } + + throw error; + } + + const watchlist = watchlistResponse?.data; + const watchlistContactID = watchlist?._pinswg_contact_value; + const watchlistRecordID = watchlist?.pinswg_watchlistid; + + if ( + typeof watchlistRecordID !== "string" || + !GUID_PATTERN.test(watchlistRecordID.trim()) || + normaliseGuid(watchlistRecordID) !== normalisedWatchedCaseID || + typeof watchlistContactID !== "string" || + !GUID_PATTERN.test(watchlistContactID.trim()) || + normaliseGuid(watchlistContactID) !== + normaliseGuid(contacts[0].contactid) + ) { + return respondError(res, { + status: 404, + code: "WATCHED_CASE_NOT_FOUND", + message: "Watched case not found" + }); + } + + const queryUrl = + "pinswg_watchlists(" + normalisedWatchedCaseID + ")"; const config = { method: "delete", @@ -56,7 +167,7 @@ export default async function ApiProxy(req, res) { } catch (error) { consoleLogger(error); return respondError(res, { - status: 400, + status: 500, code: "WATCHED_CASE_DELETE_FAILED", message: "Failed to delete watched case" }); diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 95d8d4cb..7e369af1 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -2979,48 +2979,280 @@ test("createwatchedcases catch path returns WATCHED_CASE_UPSERT_FAILED", async ( ); }); -test("deletewatchedcases returns WATCHED_CASE_ID_REQUIRED when watchedCaseID missing", async () => { +const WATCHED_CASE_ID = "11111111-1111-1111-1111-111111111111"; +const CONTACT_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_CONTACT_ID = "33333333-3333-3333-3333-333333333333"; + +const createDeleteWatchedCasesFixture = (overrides = {}) => { + const calls = { + session: [], + token: 0, + relay: [], + delete: [] + }; + const session = Object.prototype.hasOwnProperty.call(overrides, "session") + ? overrides.session + : { user: { email: "user.o'neill@test.local" } }; + const contacts = Object.prototype.hasOwnProperty.call(overrides, "contacts") + ? overrides.contacts + : [{ contactid: CONTACT_ID }]; + const watchlist = Object.prototype.hasOwnProperty.call(overrides, "watchlist") + ? overrides.watchlist + : { + pinswg_watchlistid: WATCHED_CASE_ID, + _pinswg_contact_value: CONTACT_ID + }; + const mod = loadModule("pages/api/endpoint/deletewatchedcases_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "token" }), + getServerSession: async (...args) => { + calls.session.push(args); + return session; + }, + authOptions: (req, res) => ({ req, res }), + getToken: async () => { + calls.token += 1; + return { access_token: "token" }; + }, + relayGetData: async (options) => { + calls.relay.push(options); + + if (options.queryUrl.startsWith("contacts?")) { + if (overrides.contactLookupError) { + throw overrides.contactLookupError; + } + return { data: { value: contacts } }; + } + + if (overrides.watchlistLookupError) { + throw overrides.watchlistLookupError; + } + return { data: watchlist }; + }, + escapeODataString: (value) => value.trim().replace(/'/g, "''"), + azureHeaders: (accessToken) => ({ + headers: { Authorization: `Bearer ${accessToken}` } + }), hashAPIPath: () => "&hash=expected", - axios: async () => ({ data: {} }), - consoleLogger: () => {} - }); - - const req = { query: {} }; - const res = createRes(); - await mod.default(req, res); - - assert.strictEqual(res.state.statusCode, 400); - assert.strictEqual( - res.state.jsonBody.error.code, - "WATCHED_CASE_ID_REQUIRED" - ); -}); - -test("deletewatchedcases catch path returns WATCHED_CASE_DELETE_FAILED", async () => { - const mod = loadModule("pages/api/endpoint/deletewatchedcases_api.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, - getToken: async () => ({ access_token: "token" }), - hashAPIPath: () => "&hash=expected", - axios: async () => { - throw new Error("delete failed"); + axios: async (config) => { + calls.delete.push(config); + if (overrides.deleteError) throw overrides.deleteError; + return { data: { removed: true } }; }, consoleLogger: () => {} }); - const req = { query: { watchedCaseID: "w1" } }; + return { mod, calls }; +}; + +const invokeDeleteWatchedCase = async (fixture, request = {}) => { + const req = { + method: "DELETE", + query: { watchedCaseID: WATCHED_CASE_ID }, + ...request + }; const res = createRes(); - await mod.default(req, res); + await fixture.mod.default(req, res); + return { req, res }; +}; + +test("deletewatchedcases rejects missing watchedCaseID without CRM access", async () => { + const fixture = createDeleteWatchedCasesFixture(); + const { res } = await invokeDeleteWatchedCase(fixture, { query: {} }); assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_WATCHED_CASE_ID"); + assert.strictEqual(fixture.calls.session.length, 0); + assert.strictEqual(fixture.calls.token, 0); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases rejects malformed and array watchedCaseID values", async () => { + for (const watchedCaseID of [ + "not-a-guid", + ` ${WATCHED_CASE_ID} `, + [WATCHED_CASE_ID] + ]) { + const fixture = createDeleteWatchedCasesFixture(); + const { res } = await invokeDeleteWatchedCase(fixture, { + query: { watchedCaseID } + }); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "INVALID_WATCHED_CASE_ID" + ); + assert.strictEqual(fixture.calls.token, 0); + assert.strictEqual(fixture.calls.delete.length, 0); + } +}); + +test("deletewatchedcases rejects non-DELETE methods without CRM access", async () => { + const fixture = createDeleteWatchedCasesFixture(); + const { res } = await invokeDeleteWatchedCase(fixture, { method: "GET" }); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); + assert.strictEqual(fixture.calls.session.length, 0); + assert.strictEqual(fixture.calls.token, 0); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases rejects missing session without CRM access", async () => { + const fixture = createDeleteWatchedCasesFixture({ session: null }); + const { req, res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 401); + assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED"); + assert.strictEqual(fixture.calls.session[0][0], req); + assert.strictEqual(fixture.calls.session[0][1], res); + assert.strictEqual(fixture.calls.token, 0); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases rejects session without email", async () => { + const fixture = createDeleteWatchedCasesFixture({ + session: { user: { id: "application-user" } } + }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 401); + assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED"); + assert.strictEqual(fixture.calls.token, 0); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases denies zero active CRM contacts", async () => { + const fixture = createDeleteWatchedCasesFixture({ contacts: [] }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual( + res.state.jsonBody.error.code, + "CONTACT_IDENTITY_NOT_RESOLVED" + ); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases denies multiple active CRM contacts", async () => { + const fixture = createDeleteWatchedCasesFixture({ + contacts: [{ contactid: CONTACT_ID }, { contactid: OTHER_CONTACT_ID }] + }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual( + res.state.jsonBody.error.code, + "CONTACT_IDENTITY_NOT_RESOLVED" + ); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases fails closed when CRM contact lookup fails", async () => { + const fixture = createDeleteWatchedCasesFixture({ + contactLookupError: new Error("CRM unavailable") + }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 500); assert.strictEqual( res.state.jsonBody.error.code, "WATCHED_CASE_DELETE_FAILED" ); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases returns 404 for unknown watchlist", async () => { + const lookupError = new Error("not found"); + lookupError.response = { status: 404 }; + const fixture = createDeleteWatchedCasesFixture({ + watchlistLookupError: lookupError + }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 404); + assert.strictEqual(res.state.jsonBody.error.code, "WATCHED_CASE_NOT_FOUND"); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases returns 404 for non-owned watchlist", async () => { + const fixture = createDeleteWatchedCasesFixture({ + watchlist: { + pinswg_watchlistid: WATCHED_CASE_ID, + _pinswg_contact_value: OTHER_CONTACT_ID + } + }); + const { res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 404); + assert.strictEqual(res.state.jsonBody.error.code, "WATCHED_CASE_NOT_FOUND"); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases deletes exactly once for the authenticated CRM contact", async () => { + const fixture = createDeleteWatchedCasesFixture(); + const { req, res } = await invokeDeleteWatchedCase(fixture); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + removed: true + }); + assert.strictEqual(fixture.calls.relay.length, 2); + assert.strictEqual( + fixture.calls.relay[0].queryUrl, + "contacts?$filter=emailaddress1 eq 'user.o''neill@test.local' and statuscode eq 1&$select=contactid" + ); + assert.strictEqual( + fixture.calls.relay[1].queryUrl, + `pinswg_watchlists(${WATCHED_CASE_ID})?$select=pinswg_watchlistid,_pinswg_contact_value` + ); + assert.strictEqual(fixture.calls.delete.length, 1); + assert.strictEqual(fixture.calls.delete[0].method, "delete"); + assert.match( + fixture.calls.delete[0].url, + new RegExp(`pinswg_watchlists\\(${WATCHED_CASE_ID}\\)&hash=expected$`) + ); + assert.strictEqual(fixture.calls.session[0][0], req); +}); + +test("deletewatchedcases ignores client-supplied contact and email identity", async () => { + const fixture = createDeleteWatchedCasesFixture({ + watchlist: { + pinswg_watchlistid: WATCHED_CASE_ID, + _pinswg_contact_value: OTHER_CONTACT_ID + } + }); + const { res } = await invokeDeleteWatchedCase(fixture, { + query: { + watchedCaseID: WATCHED_CASE_ID, + contactid: OTHER_CONTACT_ID, + email: "other@test.local" + }, + body: { contactid: OTHER_CONTACT_ID, email: "other@test.local" } + }); + + assert.strictEqual(res.state.statusCode, 404); + assert.match(fixture.calls.relay[0].queryUrl, /user\.o''neill@test\.local/); + assert.doesNotMatch(fixture.calls.relay[0].queryUrl, /other@test\.local/); + assert.strictEqual(fixture.calls.delete.length, 0); +}); + +test("deletewatchedcases does not let a valid HMAC override ownership", async () => { + const fixture = createDeleteWatchedCasesFixture({ + watchlist: { + pinswg_watchlistid: WATCHED_CASE_ID, + _pinswg_contact_value: OTHER_CONTACT_ID + } + }); + const { res } = await invokeDeleteWatchedCase(fixture, { + query: { watchedCaseID: WATCHED_CASE_ID, hash: "valid-signature" } + }); + + assert.strictEqual(res.state.statusCode, 404); + assert.strictEqual(res.state.jsonBody.error.code, "WATCHED_CASE_NOT_FOUND"); + assert.strictEqual(fixture.calls.delete.length, 0); }); test("deletewatchedcasesproxy returns WATCHED_CASE_ID_REQUIRED when watchedCaseID missing", async () => {