diff --git a/actions/clients/fileClient.js b/actions/clients/fileClient.js
index c58c9748..856c7f04 100644
--- a/actions/clients/fileClient.js
+++ b/actions/clients/fileClient.js
@@ -1,8 +1,8 @@
import { getJson, requestJson } from "./endpointClient";
import { getSignedJson, postSignedJson } from "./signedRequestClient";
-export const getFileJson = (url) => {
- return getJson(url);
+export const getFileJson = (url, config) => {
+ return getJson(url, config);
};
export const getSignedFileJson = async (queryUrl) => {
diff --git a/actions/services/accountDirectService.js b/actions/services/accountDirectService.js
index 20a41ec0..8e571774 100644
--- a/actions/services/accountDirectService.js
+++ b/actions/services/accountDirectService.js
@@ -11,31 +11,6 @@ export const getPersonalAccount = (contactid) => {
});
};
-export const getLogin = (emailAddress, pwd) => {
- return getJson(
- "/api/endpoint/getlogin_api?emailAddress=" +
- emailAddress +
- "&pwd=" +
- pwd
- ).catch((error) => {
- consoleLogger(error);
- });
-};
-
-export const updatePassword = (contactId, newpassword) => {
- var queryUrl = "/api/endpoint/updateaccount_api?contactId=" + contactId;
- var data = { "pinswg_custom_password": newpassword };
- var config = {
- method: "post",
- url: queryUrl,
- data: data
- };
-
- return requestJson(config).catch((error) => {
- consoleLogger(error);
- });
-};
-
export const updateAccount = async (contactId, updateBody, ssr) => {
var queryUrl =
(ssr ? BASE_URL : "") +
diff --git a/actions/services/accountService.js b/actions/services/accountService.js
index 1cf7f7aa..71edd9c1 100644
--- a/actions/services/accountService.js
+++ b/actions/services/accountService.js
@@ -1,7 +1,5 @@
import {
getPersonalAccount,
- getLogin,
- updatePassword,
updateAccount,
createAccount,
getEmailAccountCheck,
@@ -12,8 +10,6 @@ import {
export {
getPersonalAccount,
- getLogin,
- updatePassword,
updateAccount,
createAccount,
getEmailAccountCheck,
diff --git a/actions/services/caseDirectService.js b/actions/services/caseDirectService.js
index 04c4649d..253c3c23 100644
--- a/actions/services/caseDirectService.js
+++ b/actions/services/caseDirectService.js
@@ -76,35 +76,6 @@ export const getAppealID = (
});
};
-export const createNewCase = (
- appealTypeId,
- lpaID,
- contactid,
- createBody,
- containerName
-) => {
- appealTypeId = parseInt(appealTypeId);
-
- var data = createBody;
-
- var queryUrl = buildFileQuery("/api/endpoint/createcase_api", {
- appealTypeId,
- lpaID,
- contactid,
- containername: containerName
- });
-
- var config = {
- method: "post",
- url: withBaseUrl(BASE_URL, queryUrl),
- data: data
- };
-
- return requestJson(config).catch((error) => {
- consoleLogger(error);
- });
-};
-
export const createNewCaseBlob = (
appealTypeId,
lpaID,
diff --git a/actions/services/caseService.js b/actions/services/caseService.js
index 063cc5bb..5148ff92 100644
--- a/actions/services/caseService.js
+++ b/actions/services/caseService.js
@@ -4,7 +4,6 @@ import {
getSIPSEvents,
getSIPSMedia,
getAppealID,
- createNewCase,
createNewCaseBlob,
updateCase,
updateCaseBlob,
@@ -26,7 +25,6 @@ export {
getSIPSEvents,
getSIPSMedia,
getAppealID,
- createNewCase,
createNewCaseBlob,
updateCase,
updateCaseBlob,
diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js
index 436ee123..77ae6479 100644
--- a/actions/services/documentDirectService.js
+++ b/actions/services/documentDirectService.js
@@ -28,13 +28,48 @@ export const getAwaitingSubmissionFromBlob = (containerName) => {
});
};
-export const getRepsFromBlob = (containerName) => {
+export const getRepsFromBlob = (containerName, config) => {
const route = buildFileQuery("/api/file/getrepsblob", {
container: containerName
});
+ const url = withBaseUrl(BASE_URL, appendHashSuffix(route, hashAPIPath));
+
+ const hasForwardedCookie =
+ typeof config?.headers?.Cookie === "string" &&
+ config.headers.Cookie.length > 0;
+
+ if (hasForwardedCookie) {
+ let trustedDestination = false;
+
+ try {
+ const destination = new URL(url);
+ const expectedOrigin = new URL(process.env.NEXTAUTH_URL);
+
+ trustedDestination =
+ destination.origin === expectedOrigin.origin &&
+ ["http:", "https:"].includes(destination.protocol) &&
+ !destination.username &&
+ !destination.password;
+ } catch {
+ trustedDestination = false;
+ }
+
+ if (!trustedDestination) {
+ return Promise.reject(
+ new Error("Untrusted representation request destination")
+ );
+ }
+ }
+
return getFileJson(
- withBaseUrl(BASE_URL, appendHashSuffix(route, hashAPIPath))
+ url,
+ hasForwardedCookie
+ ? {
+ ...config,
+ maxRedirects: 0
+ }
+ : config
).catch((error) => {
consoleLogger(error);
});
diff --git a/components/account/changepassword.js b/components/account/changepassword.js
deleted file mode 100644
index 760be8a8..00000000
--- a/components/account/changepassword.js
+++ /dev/null
@@ -1,184 +0,0 @@
-import useTranslation from "next-translate/useTranslation";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import React, { useState } from "react";
-import { connect } from "react-redux";
-import { Field, formValueSelector, reduxForm } from "redux-form";
-import { updatePassword } from "../../actions/services/accountService";
-
-const RenderTextfield = ({
- id,
- className,
- rows,
- datafieldname,
- name,
- label,
- input,
- type,
- errorMsg,
- meta: { touched, error },
- ...custom
-}) => {
- return (
- <>
-
-
- {touched && error && (
-
- Error:{" "}
- {touched &&
- ((error && (
- {errorMsg ? errorMsg : error}
- )) ||
- (warning && {warning}))}
-
- )}
-
-
- >
- );
-};
-
-const ChangePassword = (props) => {
- const {
- footerLinks,
- pages,
- handleSubmit,
- value,
- setPasswordUpdated,
- passwordUpdated
- } = props;
- let { t, lang } = useTranslation();
-
- const router = useRouter();
- const { locale } = router;
-
- let errorsobj = {};
-
- const onHandleSubmit = (values) => {
- let contactid = props.props.accountDetails.loggedinUserId;
- let updateBody = props.props.form.changepasswordForm.values.newpassword;
- console.log(contactid, updateBody);
- updatePassword(contactid, updateBody);
- setPasswordUpdated(true);
- };
-
- const required = (value) => (value ? undefined : "Is required");
-
- const passwordsMustMatch = (value, allValues) =>
- value !== allValues.newpassword ? "Passwords do not match" : undefined;
-
- const minLength = (min) => (value) =>
- value && value.length < min
- ? `Must be at least ${min} characters`
- : undefined;
-
- const [hasErrors, setHasErrors] = useState("");
-
- return (
- <>
- {passwordUpdated != true ? (
-
- ) : (
- <>
-
-
- Go to My Portal
-
-
- >
- )}
- >
- );
-};
-
-const mapStateToProps = (state) => {
- return {
- search: state.search,
- searchResultsObj: state.searchResultsObj,
- formData: state.formData,
- appealType: state.appealType,
- accountDetails: state.accountDetails
- //form: state.form,
- };
-};
-
-const mapDispatchToProps = (dispatch) => {
- return {
- setCurrentSection: (currentSection) => {
- dispatch(setCurrentSection(currentSection));
- }
- };
-};
-
-const selector = formValueSelector("changepasswordForm"); // <-- same as form name
-
-export default connect(
- mapStateToProps,
- mapDispatchToProps
-)(
- reduxForm({
- form: "changepasswordForm",
- destroyOnUnmount: false
- })(ChangePassword)
-);
diff --git a/components/account/registerform.js b/components/account/registerform.js
index 82607dd4..51836bb9 100644
--- a/components/account/registerform.js
+++ b/components/account/registerform.js
@@ -92,11 +92,6 @@ const RegisterForm = (props) => {
? "Invalid phone number"
: undefined;
- const passwordsMatch = (value, allValues) =>
- value !== allValues.pinswg_custom_password
- ? "Passwords don't match"
- : undefined;
-
const minLength = (min) => (value) =>
value && value.length < min
? `Must be ${min} characters or more`
diff --git a/components/myportal/youraccount.js b/components/myportal/youraccount.js
index 0f6bb6cf..64fbf470 100644
--- a/components/myportal/youraccount.js
+++ b/components/myportal/youraccount.js
@@ -27,24 +27,6 @@ export default function YourAccount() {
-
- {/*
-
- {t("myportal:changepassword-card-title")}
-
-
- {" "}
- {t("myportal:changepassword-card-paragraph-one")}
-
-
-
-
*/}
>
);
diff --git a/components/newappeal/createCase.js b/components/newappeal/createCase.js
index c7e60792..46bbd0e7 100644
--- a/components/newappeal/createCase.js
+++ b/components/newappeal/createCase.js
@@ -393,13 +393,6 @@ let CreateCase = (props) => {
delete values.confirmHASCAS;
- // createNewCase(
- // appTypeCollection.appealTypeID,
- // values.lpaTypes,
- // props.loggedInUser,
- // values,
- // props.containerID
- // )
const selectedLpaName = props.LPAData.LPAData.value.filter((obj) => {
return obj.accountid === values.lpaTypes;
})[0].name;
diff --git a/lib/auth/resolveCrmLocale.js b/lib/auth/resolveCrmLocale.js
deleted file mode 100644
index 0677ab16..00000000
--- a/lib/auth/resolveCrmLocale.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { azureHeadersPaged } from "../../actions/core/headers";
-import { escapeODataString } from "../../actions/core/guards";
-import { consoleLogger } from "../../actions/core/logger";
-import { relayGetData } from "../../pages/api/middleware/relayForwarding";
-import { RELAY_POLICY_STRICT_LOGIN } from "../../pages/api/middleware/relayPolicyPresets";
-
-const WELSH_LANGUAGE_CODE = 846040000;
-const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
-export const resolveCrmLocale = async (email) => {
- if (typeof email !== "string") return null;
-
- const normalizedEmail = email.trim().toLowerCase();
-
- if (!EMAIL_PATTERN.test(normalizedEmail)) return null;
-
- const queryUrl =
- "contacts?$filter=emailaddress1 eq '" +
- escapeODataString(normalizedEmail) +
- "' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage";
-
- try {
- const { data } = await relayGetData({
- queryUrl,
- requestOptionsBuilder: (accessToken) =>
- azureHeadersPaged(accessToken),
- relayPolicy: RELAY_POLICY_STRICT_LOGIN
- });
- const preferredLanguage = data?.value?.[0]?.pinswg_preferredlanguage;
-
- if (preferredLanguage === WELSH_LANGUAGE_CODE) return "cy";
- if (preferredLanguage != null) return "en";
-
- return null;
- } catch (error) {
- consoleLogger({
- name: "ResolveCrmLocale",
- message: "CRM locale lookup failed"
- });
- return null;
- }
-};
\ No newline at end of file
diff --git a/lib/representation/pageLoaders.js b/lib/representation/pageLoaders.js
index 60b5b4f4..853e568a 100644
--- a/lib/representation/pageLoaders.js
+++ b/lib/representation/pageLoaders.js
@@ -120,7 +120,9 @@ export const loadRepresentationBootstrap = async ({ ctx }) => {
const [accountDetails, myRepresentations] = await Promise.all([
getPersonalAccount(loggedInUser),
- getRepsFromBlob(thisSession.user.id)
+ getRepsFromBlob(thisSession.user.id, {
+ headers: { Cookie: ctx.req?.headers?.cookie || "" }
+ })
]);
const myRepresentationsDetails = await getDetails(
diff --git a/lib/routing/breadcrumbRendererFactories.js b/lib/routing/breadcrumbRendererFactories.js
index 0d0961db..8bf1bec3 100644
--- a/lib/routing/breadcrumbRendererFactories.js
+++ b/lib/routing/breadcrumbRendererFactories.js
@@ -472,15 +472,6 @@ export const createDetailAndAccountRouteRenderers = ({
{t("account:account-title")}
>
- ),
-
- "/account/changepassword": () => (
- <>
- {renderLinkCrumb(cyMyPortalHref, t("common:breadcrumb-my-portal"))}
-
- Update your password
-
- >
)
});
diff --git a/lib/server/getCrmPersonalAccount.js b/lib/server/getCrmPersonalAccount.js
new file mode 100644
index 00000000..4d15ec17
--- /dev/null
+++ b/lib/server/getCrmPersonalAccount.js
@@ -0,0 +1,16 @@
+import { relayGetData } from "../../pages/api/middleware/relayForwarding";
+import { RELAY_POLICY_LOOKUP } from "../../pages/api/middleware/relayPolicyPresets";
+
+export const getCrmPersonalAccount = async (contactId) => {
+ const queryUrl =
+ "contacts(" +
+ contactId +
+ ")?$select=firstname, lastname, emailaddress1, telephone1, pinswg_companyname, address1_line1,address1_line2,address1_city, address1_county,address1_postalcode,pinswg_typeofinvolvement,pinswg_contact_associatedlpa,pinswg_preferredlanguage&$count=true";
+
+ const { data } = await relayGetData({
+ queryUrl,
+ relayPolicy: RELAY_POLICY_LOOKUP
+ });
+
+ return data;
+};
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/account/changepassword.js b/pages/account/changepassword.js
deleted file mode 100644
index 92b19237..00000000
--- a/pages/account/changepassword.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import Link from "next/link";
-import _ from "lodash";
-import Head from "next/head";
-import Header from "../../components/header";
-import Banner from "../../components/banner";
-import CookieBanner from "../../components/cookieBanner";
-import Breadcrumbs from "../../components/breadcrumbs";
-import Footer from "../../components/footer";
-
-import { useSelector, shallowEqual, connect } from "react-redux";
-import { wrapper } from "../../store/store";
-
-import { useRouter } from "next/router";
-import { useState } from "react";
-import useTranslation from "next-translate/useTranslation";
-import { JSONPath as jsonpath } from "jsonpath-plus";
-import { reduxForm, formValueSelector } from "redux-form";
-
-import ChangePassword from "../../components/account/changepassword";
-import xpath from "xpath";
-import {
- setAppealType,
- setAppealTypeTitle,
- setAppealTypeID,
- setAppealLPA,
- getAppealTypeObj
-} from "../../store/appealType/action";
-import {
- getAppealsTypes,
- getLPA
-} from "../../actions/services/referenceDataService";
-import { setLPA } from "../../store/lpa/action";
-
-import { getSession, useSession } from "next-auth/react";
-import NoSessionWarning from "../../components/nosession";
-
-const Home = (props) => {
- const { footerLinks, pages, formData } = props;
- let { t, lang } = useTranslation();
-
- const router = useRouter();
- const { locale } = router;
- const { appealtypes } = router.query;
-
- const { appealType, LPAData } = props;
- const [passwordUpdated, setPasswordUpdated] = useState(false);
-
- const { data: session, status } = useSession();
-
- if (status === "loading") {
- return ;
- }
-
- if (status === "unauthenticated") {
- router.push("/auth/signin");
- return ;
- }
-
- return (
-
-
-
- {t("search:page-title")} - {t("common:service-name")}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {passwordUpdated == true ? (
-
- Your password has been changed
-
- ) : (
- Change password
- )}
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const mapStateToProps = (state) => {
- return {
- search: state.search,
- searchResultsObj: state.searchResultsObj,
- formData: state.formData,
- appealType: state.appealType,
- LPAData: state.LPAData,
- form: state.form,
- accountDetails: state.accountDetails
- };
-};
-
-export default connect(mapStateToProps)(Home);
diff --git a/pages/api/admin/getrepsblobproxy.js b/pages/api/admin/getrepsblobproxy.js
index 129671d7..a8b1ba65 100644
--- a/pages/api/admin/getrepsblobproxy.js
+++ b/pages/api/admin/getrepsblobproxy.js
@@ -1,9 +1,11 @@
+import { BASE_URL } from "../../../actions/core/env";
import { getToken } from "../../../actions/core/token";
import { azureHeaders } from "../../../actions/core/headers";
import { consoleLogger } from "../../../actions/core/logger";
import { hashAPIPath } from "../../../actions/core/hash";
import axios from "axios";
import {
+ ADMIN_SESSION_COOKIE,
hasTrustedSameOrigin,
readAdminSessionCookie,
setAdminAuthNoStore,
@@ -11,7 +13,16 @@ import {
} from "../../../lib/admin/adminAuth";
import { respondError, respondSuccess } from "../middleware/apiResponse";
-const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`;
+const isTrustedInternalDestination = () => {
+ try {
+ return (
+ new URL(BASE_URL).origin ===
+ new URL(process.env.NEXTAUTH_URL).origin
+ );
+ } catch {
+ return false;
+ }
+};
const forbidden = (res) =>
respondError(res, {
@@ -39,10 +50,7 @@ export default async function handler(req, res) {
}
const adminSession = readAdminSessionCookie(req);
- if (
- !validateAdminSession(adminSession) ||
- !hasTrustedSameOrigin(req)
- ) {
+ if (!validateAdminSession(adminSession) || !hasTrustedSameOrigin(req)) {
return forbidden(res);
}
@@ -55,14 +63,27 @@ export default async function handler(req, res) {
});
}
+ if (!isTrustedInternalDestination()) {
+ return forbidden(res);
+ }
+
const token = await getToken();
const queryUrl =
"/api/file/getrepsblob?container=" + encodeURIComponent(container);
try {
+ const config = azureHeaders(token.access_token);
+
const { data } = await axios.get(
BASE_URL + queryUrl + hashAPIPath(queryUrl),
- azureHeaders(token.access_token)
+ {
+ ...config,
+ headers: {
+ ...config.headers,
+ Cookie: `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(adminSession)}`
+ },
+ maxRedirects: 0
+ }
);
return respondSuccess(res, data);
} catch (error) {
@@ -73,4 +94,4 @@ export default async function handler(req, res) {
message: "Failed to fetch representation blobs"
});
}
-}
\ No newline at end of file
+}
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index f953bf13..ac88da54 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -14,8 +14,8 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import NextAuth from "next-auth";
import EmailProvider from "../../../lib/auth/email-provider";
-import { resolveCrmLocale } from "../../../lib/auth/resolveCrmLocale";
import { consoleLogger, redactSensitive } from "../../../actions/core/logger";
+import { getPortalLogin } from "../../../actions/services/accountService";
const prisma = new PrismaClient();
@@ -53,6 +53,8 @@ const createVerificationTokenSynchronizationAdapter = (
};
};
+const WELSH_LANGUAGE_CODE = 846040000;
+
const authDiagnostic = (event, metadata = {}) => {
try {
const safeMeta = JSON.parse(JSON.stringify(metadata));
@@ -82,6 +84,29 @@ const appendParamsAndPathToNewUrl = (fromUrl, toUrl) => {
}
};
+const resolveCrmLocale = async (email) => {
+ if (!email) return null;
+
+ try {
+ const portalUserObj = await getPortalLogin(email);
+ const preferredLanguage =
+ portalUserObj?.value?.[0]?.pinswg_preferredlanguage;
+
+ if (preferredLanguage === WELSH_LANGUAGE_CODE) {
+ return "cy";
+ }
+
+ if (preferredLanguage != null) {
+ return "en";
+ }
+
+ return null;
+ } catch (error) {
+ consoleLogger(error);
+ return null;
+ }
+};
+
const resolveEffectiveLocale = async (req, email) => {
const crmLocale = await resolveCrmLocale(email);
if (crmLocale) {
diff --git a/pages/api/auth/resolve-locale.js b/pages/api/auth/resolve-locale.js
index f029dce2..f89bf9df 100644
--- a/pages/api/auth/resolve-locale.js
+++ b/pages/api/auth/resolve-locale.js
@@ -1,3 +1,8 @@
+import { getPortalLogin } from "../../../actions/services/accountService";
+import { consoleLogger } from "../../../actions/core/logger";
+
+const WELSH_LANGUAGE_CODE = 846040000;
+
const resolveRequestLocale = (req) =>
req?.query?.locale ||
req?.body?.locale ||
@@ -9,8 +14,30 @@ export default async function handler(req, res) {
return res.status(405).json({ message: "Method not allowed" });
}
- const requestedLocale = resolveRequestLocale(req);
- const locale = requestedLocale === "cy" ? "cy" : "en";
+ const email = String(req.body?.email || "")
+ .trim()
+ .toLowerCase();
+ const sessionLocale = resolveRequestLocale(req);
- return res.status(200).json({ locale });
+ if (!email) {
+ return res.status(200).json({ locale: sessionLocale });
+ }
+
+ try {
+ const portalUserObj = await getPortalLogin(email);
+ const preferredLanguage =
+ portalUserObj?.value?.[0]?.pinswg_preferredlanguage;
+
+ const locale =
+ preferredLanguage === WELSH_LANGUAGE_CODE
+ ? "cy"
+ : preferredLanguage != null
+ ? "en"
+ : sessionLocale;
+
+ return res.status(200).json({ locale });
+ } catch (error) {
+ consoleLogger(error);
+ return res.status(200).json({ locale: sessionLocale });
+ }
}
diff --git a/pages/api/endpoint/createcase_api.js b/pages/api/endpoint/createcase_api.js
deleted file mode 100644
index 469b9e3c..00000000
--- a/pages/api/endpoint/createcase_api.js
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * @swagger
- * /api/endpoint/createcase_api:
- * post:
- * tags: [Case]
- * description: Create case
- * summary: Phase 2
- * parameters:
- * - name: body
- * in: requestbody
- * required: true
- * example: {}
- * schema:
- * type: object
- * - name: contactid
- * in: query
- * required: true
- * example: pinswg_planningappeals78s
- * schema:
- * type: string
- * - name: appealTypeId
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * - name: containername
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * - name: lpaID
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Success
- */
-
-import axios from "axios";
-import { consoleLogger } from "../../../actions/core/logger";
-import { getToken } from "../../../actions/core/token";
-import { getCaseBlob } from "../../../actions/azurestorage";
-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/";
-
-export default async function ApiProxy(req, res) {
- const createCaseBody = req.body;
- const contactid = req.query.contactid;
- const appealTypeId = req.query.appealTypeId;
- const containerName = req.query.containername;
- const lpaID = req.query.lpaID;
- const queryUrl = "incidents";
-
- if (!createCaseBody || typeof createCaseBody !== "object") {
- return respondError(res, {
- status: 400,
- code: "CASE_PAYLOAD_REQUIRED",
- message: "Case payload is required"
- });
- }
- if (typeof contactid !== "string" || contactid.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "CONTACT_ID_REQUIRED",
- message: "contactid is required"
- });
- }
- if (typeof appealTypeId !== "string" || appealTypeId.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "APPEAL_TYPE_ID_REQUIRED",
- message: "appealTypeId is required"
- });
- }
- if (
- typeof containerName !== "string" ||
- containerName.trim().length === 0
- ) {
- return respondError(res, {
- status: 400,
- code: "CONTAINER_NAME_REQUIRED",
- message: "containername is required"
- });
- }
- if (typeof lpaID !== "string" || lpaID.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "LPA_ID_REQUIRED",
- message: "lpaID is required"
- });
- }
-
- try {
- const token = await getToken();
-
- const data = {
- "title": "insertion test case",
- "caseorigincode": 3,
- "servicestage": 1,
- "customerid_contact@odata.bind": "/contacts(" + contactid + ")",
- "pinswg_appealcasetype": appealTypeId,
- "pinswg_AssociatedLPA@odata.bind": "/accounts(" + lpaID + ")"
- };
- const newData = Object.assign(data, createCaseBody);
-
- delete newData.lpaTypes;
- delete newData.appealTypes;
-
- console.log("/////Create Case:\n", newData, "\n//////////////");
-
- const config = {
- method: "post",
- 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(newData)
- };
-
- const { data: responseData } = await axios(config);
- await getCaseBlob(
- containerName,
- responseData.ticketnumber,
- createCaseBody
- );
-
- return respondSuccess(res, responseData);
- } catch (error) {
- consoleLogger(error);
- return respondError(res, {
- status: 400,
- code: "CASE_CREATE_FAILED",
- message: "Failed to create case"
- });
- }
-}
diff --git a/pages/api/endpoint/createcrmtask_api.js b/pages/api/endpoint/createcrmtask_api.js
index af2f66b1..964a3247 100644
--- a/pages/api/endpoint/createcrmtask_api.js
+++ b/pages/api/endpoint/createcrmtask_api.js
@@ -1,46 +1,3 @@
-/**
- * @swagger
- * /api/endpoint/createcase_api:
- * post:
- * tags: [Case]
- * description: Create case
- * summary: Phase 2
- * parameters:
- * - name: body
- * in: requestbody
- * required: true
- * example: {}
- * schema:
- * type: object
- * - name: contactid
- * in: query
- * required: true
- * example: pinswg_planningappeals78s
- * schema:
- * type: string
- * - name: appealTypeId
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * - name: containername
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * - name: lpaID
- * in: query
- * required: true
- * example: pinswg_planningappeals78id
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Success
- */
-
import axios from "axios";
import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards";
import { consoleLogger } from "../../../actions/core/logger";
diff --git a/pages/api/endpoint/createwatchedcases_api.js b/pages/api/endpoint/createwatchedcases_api.js
index 0c67b6a2..aad9fff9 100644
--- a/pages/api/endpoint/createwatchedcases_api.js
+++ b/pages/api/endpoint/createwatchedcases_api.js
@@ -16,38 +16,73 @@ import { getToken } from "../../../actions/core/token";
import { hashAPIPath } from "../../../actions/core/hash";
import { respondError, respondSuccess } from "../middleware/apiResponse";
import { relayGetData } from "../middleware/relayForwarding";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../auth/[...nextauth]";
+import { azureHeaders } from "../../../actions/core/headers";
+import { escapeODataString } from "../../../actions/core/guards";
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 recordExists = async (incidentId, contactId, accessToken) => {
const filter = `$filter=pinswg_WatchedCase/incidentid eq ${incidentId} and pinswg_Contact/contactid eq ${contactId}`;
- const queryUrl = `pinswg_watchlists?${filter}`;
+ const queryUrl =
+ `pinswg_watchlists?${filter}` +
+ "&$select=pinswg_watchlistid,_pinswg_contact_value,_pinswg_watchedcase_value,pinswg_representationsubmitted";
- try {
- const response = await relayGetData({
- queryUrl,
- accessToken,
- requestOptionsBuilder: (token) => ({
- headers: {
- Authorization: "Bearer " + token,
- Accept: "application/json"
- }
- })
- });
+ const response = await relayGetData({
+ queryUrl,
+ accessToken,
+ requestOptionsBuilder: (token) => ({
+ headers: {
+ Authorization: "Bearer " + token,
+ Accept: "application/json"
+ }
+ })
+ });
- if (response.data.value && response.data.value.length > 0) {
- return response.data.value[0]; // return the existing record
- }
+ const records = response?.data?.value;
- return null;
- } catch (err) {
- throw err;
+ if (!Array.isArray(records)) {
+ return {
+ status: "invalid",
+ records: []
+ };
}
+
+ return {
+ status: "found",
+ records
+ };
};
export default async function ApiProxy(req, res) {
+ if (req.method !== "POST") {
+ return respondError(res, {
+ status: 405,
+ code: "METHOD_NOT_ALLOWED",
+ message: "Only POST 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"
+ });
+ }
+
const data = req.body;
const watchedCaseBind = data?.["pinswg_WatchedCase@odata.bind"];
const contactBind = data?.["pinswg_Contact@odata.bind"];
@@ -66,8 +101,13 @@ export default async function ApiProxy(req, res) {
});
}
- const watchedCaseMatch = watchedCaseBind.match(/\(([^)]+)\)/);
- const contactMatch = contactBind.match(/\(([^)]+)\)/);
+ const watchedCaseMatch = watchedCaseBind.match(
+ /^\/incidents\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)$/i
+ );
+
+ const contactMatch = contactBind.match(
+ /^\/contacts\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)$/i
+ );
if (!watchedCaseMatch || !contactMatch) {
return respondError(res, {
@@ -79,26 +119,118 @@ export default async function ApiProxy(req, res) {
try {
const token = await getToken();
-
const incidentId = watchedCaseMatch[1];
const contactId = contactMatch[1];
- const existingRecord = await recordExists(
+ 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: (accessToken) => azureHeaders(accessToken)
+ });
+
+ const contacts = contactResponse?.data?.value;
+
+ if (
+ !Array.isArray(contacts) ||
+ 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"
+ });
+ }
+
+ if (
+ contactId.trim().toLowerCase() !==
+ contacts[0].contactid.trim().toLowerCase()
+ ) {
+ return respondError(res, {
+ status: 403,
+ code: "CONTACT_OWNERSHIP_MISMATCH",
+ message: "Request not permitted"
+ });
+ }
+
+ const lookup = await recordExists(
incidentId,
contactId,
token.access_token
);
- let method, queryUrl;
-
- if (existingRecord) {
- method = "patch";
- queryUrl = `pinswg_watchlists(${existingRecord.pinswg_watchlistid})`;
- } else {
- method = "post";
- queryUrl = "pinswg_watchlists";
+ if (lookup.status === "invalid") {
+ return respondError(res, {
+ status: 500,
+ code: "WATCHLIST_LOOKUP_FAILED",
+ message: "Unable to verify existing watched-case records"
+ });
}
+ const records = lookup.records;
+
+ const isRepresentationSubmission =
+ data?.pinswg_representationsubmitted != null;
+
+ // Validate every returned record before considering it for an update.
+ const validRecords = records.every((record) => {
+ const recordID = record?.pinswg_watchlistid;
+ const recordContactID = record?._pinswg_contact_value;
+ const recordCaseID = record?._pinswg_watchedcase_value;
+
+ return (
+ typeof recordID === "string" &&
+ GUID_PATTERN.test(recordID) &&
+ typeof recordContactID === "string" &&
+ GUID_PATTERN.test(recordContactID) &&
+ typeof recordCaseID === "string" &&
+ GUID_PATTERN.test(recordCaseID) &&
+ recordContactID.toLowerCase() === contactId.toLowerCase() &&
+ recordCaseID.toLowerCase() === incidentId.toLowerCase()
+ );
+ });
+
+ if (!validRecords) {
+ return respondError(res, {
+ status: 403,
+ code: "WATCHLIST_OWNERSHIP_MISMATCH",
+ message: "Request not permitted"
+ });
+ }
+
+ const watchingOnlyRecords = records.filter(
+ (record) => record.pinswg_representationsubmitted == null
+ );
+
+ let existingRecord;
+
+ if (isRepresentationSubmission) {
+ // Reuse a watching-only record for the first representation.
+ // Never overwrite an earlier representation submission.
+ existingRecord =
+ watchingOnlyRecords.length === 1
+ ? watchingOnlyRecords[0]
+ : null;
+ } else {
+ // Watching or subscribing: prefer a watching-only record.
+ // Otherwise reuse an existing representation-associated record.
+ existingRecord = watchingOnlyRecords[0] ?? records[0] ?? null;
+ }
+
+ const method = existingRecord ? "patch" : "post";
+
+ const queryUrl = existingRecord
+ ? `pinswg_watchlists(${existingRecord.pinswg_watchlistid})`
+ : "pinswg_watchlists";
+
const config = {
method: method,
url: WEBAPI_URL + queryUrl + hashAPIPath(queryUrl),
@@ -110,7 +242,10 @@ export default async function ApiProxy(req, res) {
Authorization: "Bearer " + token.access_token,
"Content-Type": "application/json"
},
- data: JSON.stringify(data)
+ data: JSON.stringify({
+ ...data,
+ "pinswg_Contact@odata.bind": `/contacts(${contacts[0].contactid.trim()})`
+ })
};
const result = await axios(config);
diff --git a/pages/api/endpoint/gethash_api.js b/pages/api/endpoint/gethash_api.js
index 933eefb1..fb3e28f9 100644
--- a/pages/api/endpoint/gethash_api.js
+++ b/pages/api/endpoint/gethash_api.js
@@ -39,6 +39,7 @@ ApiProxy.get(async (req, res) => {
const allowedPrefix = [
"/api/endpoint/getportallogin_api",
"/api/endpoint/deletewatchedcases_api",
+ "/api/subscriptions/delete-watched-case",
"/api/file/upload",
"/api/file/uploadsinglefile",
"/api/file/deleteblobcase",
@@ -53,7 +54,7 @@ ApiProxy.get(async (req, res) => {
typeof queryPath !== "string" ||
queryPath.length === 0 ||
!queryPath.startsWith("/api/") ||
- !allowedPrefix.some((prefix) => queryPath.startsWith(prefix)) ||
+ !allowedPrefix.includes(queryPath) ||
queryPath.includes("/api/endpoint/gethash_api")
) {
return respondError(res, {
diff --git a/pages/api/endpoint/getlogin_api.js b/pages/api/endpoint/getlogin_api.js
deleted file mode 100644
index 3e00ea8a..00000000
--- a/pages/api/endpoint/getlogin_api.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @swagger
- * /api/endpoint/getlogin_api:
- * get:
- * tags: [Login]
- * description: Returns the hello world
- * summary: Phase 2
- * parameters:
- * - name: emailaddress
- * in: query
- * required: true
- * schema:
- * type: string
- * - name: pwd
- * in: query
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: hello world
- */
-
-import { azureHeadersPaged } from "../../../actions/core/headers";
-import { respondError } from "../middleware/apiResponse";
-import { relayGet } from "../middleware/relayForwarding";
-import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets";
-
-export default async function ApiProxy(req, res) {
- const emailAddress = req.query.emailAddress;
- const pwd = req.query.pwd;
-
- if (typeof emailAddress !== "string" || emailAddress.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "EMAIL_ADDRESS_REQUIRED",
- message: "emailAddress is required"
- });
- }
-
- if (typeof pwd !== "string" || pwd.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "PASSWORD_REQUIRED",
- message: "pwd is required"
- });
- }
-
- const queryUrl =
- "contacts?$filter=emailaddress1 eq '" +
- emailAddress +
- "' and pinswg_custom_password eq '" +
- pwd +
- "'&$count=true&$select=emailaddress1,contactid,pinswg_custom_password,yomifullname,firstname,lastname";
-
- const relayPolicy = RELAY_POLICY_STRICT_LOGIN;
-
- return relayGet({
- queryUrl,
- res,
- requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
- relayPolicy,
- errorResponse: {
- status: 400,
- code: "LOGIN_FETCH_FAILED",
- message: "Failed to fetch login details"
- }
- });
-}
diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js
index c690be08..00871021 100644
--- a/pages/api/endpoint/getportallogin_api.js
+++ b/pages/api/endpoint/getportallogin_api.js
@@ -17,61 +17,20 @@
*/
import { azureHeadersPaged } from "../../../actions/core/headers";
-import { escapeODataString } from "../../../actions/core/guards";
import { hashAPIPath } from "../../../actions/core/hash";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "../auth/[...nextauth]";
import { respondError } from "../middleware/apiResponse";
import { relayGet } from "../middleware/relayForwarding";
import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets";
-const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/;
-
export default async function ApiProxy(req, res) {
- if (req.method !== "GET") {
- return respondError(res, {
- status: 405,
- code: "METHOD_NOT_ALLOWED",
- message: "Only GET is supported"
- });
- }
-
- const session = await getServerSession(req, res, authOptions(req, res));
- const authenticatedEmail = session?.user?.email;
-
- if (
- typeof authenticatedEmail !== "string" ||
- !EMAIL_PATTERN.test(authenticatedEmail.trim())
- ) {
- return respondError(res, {
- status: 401,
- code: "UNAUTHENTICATED",
- message: "Authentication required"
- });
- }
-
const emailAddress = req.query.emailAddress;
const checkHash = req.query.hash;
- if (
- typeof emailAddress !== "string" ||
- !EMAIL_PATTERN.test(emailAddress.trim())
- ) {
+ if (typeof emailAddress !== "string" || emailAddress.trim().length === 0) {
return respondError(res, {
status: 400,
- code: "INVALID_EMAIL_ADDRESS",
- message: "emailAddress must be a valid email address"
- });
- }
-
- const normalizedAuthenticatedEmail = authenticatedEmail.trim().toLowerCase();
- const normalizedRequestedEmail = emailAddress.trim().toLowerCase();
-
- if (normalizedRequestedEmail !== normalizedAuthenticatedEmail) {
- return respondError(res, {
- status: 403,
- code: "FORBIDDEN",
- message: "Request not permitted"
+ code: "EMAIL_ADDRESS_REQUIRED",
+ message: "emailAddress is required"
});
}
@@ -104,7 +63,7 @@ export default async function ApiProxy(req, res) {
const queryUrl =
"contacts?$filter=emailaddress1 eq '" +
- escapeODataString(normalizedAuthenticatedEmail) +
+ emailAddress +
"' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname";
const relayPolicy = RELAY_POLICY_STRICT_LOGIN;
diff --git a/pages/api/endpoint/updateaccount_api.js b/pages/api/endpoint/updateaccount_api.js
index 7f7c1f34..1f3149f7 100644
--- a/pages/api/endpoint/updateaccount_api.js
+++ b/pages/api/endpoint/updateaccount_api.js
@@ -1,37 +1,68 @@
-/**
- * @swagger
- * /api/endpoint/updateaccount_api:
- * patch:
- * tags: [Account]
- * description: Update account
- * summary: Phase 2
- * responses:
- * 200:
- * 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";
+import { updateCrmContact } from "../../../lib/server/updateCrmContact";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../auth/[...nextauth]";
+import { getToken } from "../../../actions/core/token";
+import { relayGetData } from "../middleware/relayForwarding";
+import { azureHeaders } from "../../../actions/core/headers";
+import { escapeODataString } from "../../../actions/core/guards";
-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 ALLOWED_ACCOUNT_FIELDS = new Set([
+ "pinswg_preferredlanguage",
+ "firstname",
+ "lastname",
+ "telephone1",
+ "pinswg_companyname",
+ "address1_line1",
+ "address1_line2",
+ "address1_city",
+ "address1_county",
+ "address1_postalcode"
+]);
export default async function ApiProxy(req, res) {
- const contactId = req.query.contactId;
-
- if (typeof contactId !== "string" || contactId.trim().length === 0) {
+ if (req.method !== "POST") {
+ res.setHeader("Allow", "POST");
return respondError(res, {
- status: 400,
- code: "CONTACT_ID_REQUIRED",
- message: "contactId is required"
+ status: 405,
+ code: "METHOD_NOT_ALLOWED",
+ message: "Method not allowed"
});
}
- if (!req.body || Object.keys(req.body).length === 0) {
+ const session = await getServerSession(req, res, authOptions(req, res));
+
+ if (
+ typeof session?.user?.email !== "string" ||
+ session.user.email.trim().length === 0
+ ) {
+ return respondError(res, {
+ status: 401,
+ code: "UNAUTHENTICATED",
+ message: "Authentication required"
+ });
+ }
+
+ const contactId = req.query.contactId;
+
+ if (typeof contactId !== "string" || !GUID_PATTERN.test(contactId.trim())) {
+ return respondError(res, {
+ status: 400,
+ code: "INVALID_CONTACT_ID",
+ message: "A valid contactId is required"
+ });
+ }
+
+ if (
+ !req.body ||
+ typeof req.body !== "object" ||
+ Array.isArray(req.body) ||
+ Object.keys(req.body).length === 0
+ ) {
return respondError(res, {
status: 400,
code: "ACCOUNT_UPDATE_PAYLOAD_REQUIRED",
@@ -41,25 +72,62 @@ 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 escapedEmail = escapeODataString(session.user.email);
- const response = await axios(config);
- return respondSuccess(res, response.data);
+ const contactQueryUrl =
+ "contacts?$filter=emailaddress1 eq '" +
+ escapedEmail +
+ "' and statuscode eq 1&$select=contactid";
+
+ const contactResponse = await relayGetData({
+ queryUrl: contactQueryUrl,
+ accessToken: token.access_token,
+ requestOptionsBuilder: (accessToken) => azureHeaders(accessToken)
+ });
+
+ const contacts = contactResponse?.data?.value;
+
+ if (
+ !Array.isArray(contacts) ||
+ 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"
+ });
+ }
+
+ if (
+ contactId.trim().toLowerCase() !==
+ contacts[0].contactid.trim().toLowerCase()
+ ) {
+ return respondError(res, {
+ status: 403,
+ code: "CONTACT_OWNERSHIP_MISMATCH",
+ message: "Request not permitted"
+ });
+ }
+
+ const updatePayload = Object.fromEntries(
+ Object.entries(req.body).filter(([field]) =>
+ ALLOWED_ACCOUNT_FIELDS.has(field)
+ )
+ );
+
+ if (Object.keys(updatePayload).length === 0) {
+ return respondError(res, {
+ status: 400,
+ code: "NO_ALLOWED_ACCOUNT_FIELDS",
+ message: "No permitted account fields supplied"
+ });
+ }
+
+ const data = await updateCrmContact(contactId.trim(), updatePayload);
+ return respondSuccess(res, data);
} catch (error) {
consoleLogger(error);
return respondError(res, {
diff --git a/pages/api/endpoint/updatepassword_api.js b/pages/api/endpoint/updatepassword_api.js
deleted file mode 100644
index dc8a64cb..00000000
--- a/pages/api/endpoint/updatepassword_api.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @swagger
- * /api/endpoint/updatepassword_api:
- * patch:
- * tags: [Account]
- * description: Update password
- * summary: Phase 2
- * responses:
- * 200:
- * 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/";
-
-export default async function ApiProxy(req, res) {
- const contactId = req.query.contactId;
-
- if (typeof contactId !== "string" || contactId.trim().length === 0) {
- return respondError(res, {
- status: 400,
- code: "CONTACT_ID_REQUIRED",
- message: "contactId is required"
- });
- }
-
- if (!req.body || Object.keys(req.body).length === 0) {
- return respondError(res, {
- status: 400,
- code: "PASSWORD_UPDATE_PAYLOAD_REQUIRED",
- message: "Password update payload is required"
- });
- }
-
- 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);
- } catch (error) {
- consoleLogger(error);
- return respondError(res, {
- status: 400,
- code: "PASSWORD_UPDATE_FAILED",
- message: "Failed to update password"
- });
- }
-}
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/pages/api/file/generatepdf.js b/pages/api/file/generatepdf.js
index 5000ad9d..67d7e05d 100644
--- a/pages/api/file/generatepdf.js
+++ b/pages/api/file/generatepdf.js
@@ -222,9 +222,11 @@ export default async function handler(req, res) {
if (
!isValidContainer(containerID) ||
!isValidDraftFolder(caseRef) ||
- (Object.prototype.hasOwnProperty.call(reqBodyobj, "ticketnumber") &&
- Object.prototype.hasOwnProperty.call(reqBodyobj, "caseRef") &&
- reqBodyobj.ticketnumber !== reqBodyobj.caseRef) ||
+ ["ticketnumber", "caseRef"].some(
+ (key) =>
+ Object.prototype.hasOwnProperty.call(reqBodyobj, key) &&
+ !isValidDraftFolder(reqBodyobj[key])
+ ) ||
(typeof req.query.download !== "undefined" &&
typeof req.query.download !== "string") ||
[
diff --git a/pages/api/file/getrepsblob.js b/pages/api/file/getrepsblob.js
index 45dff296..ef58e15e 100644
--- a/pages/api/file/getrepsblob.js
+++ b/pages/api/file/getrepsblob.js
@@ -38,10 +38,23 @@ import middleware from "../middleware/middleware";
import { hashAPIPath } from "../../../actions/core/hash";
import { consoleLogger } from "../../../actions/core/logger";
import { respondError, respondSuccess } from "../middleware/apiResponse";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "../auth/[...nextauth]";
+import {
+ readAdminSessionCookie,
+ validateAdminSession
+} from "../../../lib/admin/adminAuth";
const ApiProxy = nextConnect();
ApiProxy.use(middleware);
+const forbidden = (res) =>
+ respondError(res, {
+ status: 403,
+ code: "FORBIDDEN",
+ message: "Request not permitted"
+ });
+
ApiProxy.get(async (req, res) => {
const containerName = req.query.container;
const checkHash = req.query.hash;
@@ -59,6 +72,17 @@ ApiProxy.get(async (req, res) => {
});
}
+ const session = await getServerSession(req, res, authOptions(req, res));
+ const isPortalOwner =
+ typeof session?.user?.id === "string" &&
+ session.user.id.length > 0 &&
+ session.user.id === containerName;
+ const isAdministrator = validateAdminSession(readAdminSessionCookie(req));
+
+ if (!isPortalOwner && !isAdministrator) {
+ return forbidden(res);
+ }
+
const hashCandidatePaths = [
"/api/file/getrepsblob?container=" + containerName,
"/api/file/getrepsblob?container=" + encodeURIComponent(containerName)
diff --git a/pages/api/file/getrepsblobproxy.js b/pages/api/file/getrepsblobproxy.js
index cf279da2..126237e6 100644
--- a/pages/api/file/getrepsblobproxy.js
+++ b/pages/api/file/getrepsblobproxy.js
@@ -1,3 +1,4 @@
+import { BASE_URL } from "../../../actions/core/env";
import { getToken } from "../../../actions/core/token";
import { azureHeaders } from "../../../actions/core/headers";
import { consoleLogger } from "../../../actions/core/logger";
@@ -7,7 +8,16 @@ import { respondError, respondSuccess } from "../middleware/apiResponse";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
-const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`;
+const isTrustedInternalDestination = () => {
+ try {
+ return (
+ new URL(BASE_URL).origin ===
+ new URL(process.env.NEXTAUTH_URL).origin
+ );
+ } catch {
+ return false;
+ }
+};
const forbidden = (res) =>
respondError(res, {
@@ -60,15 +70,28 @@ export default async function ApiProxy(req, res) {
return forbidden(res);
}
+ if (!isTrustedInternalDestination()) {
+ return forbidden(res);
+ }
+
const token = await getToken();
const queryUrl =
"/api/file/getrepsblob?container=" + encodeURIComponent(containerName);
try {
+ const config = azureHeaders(token.access_token);
+
const { data } = await axios.get(
BASE_URL + queryUrl + hashAPIPath(queryUrl),
- azureHeaders(token.access_token)
+ {
+ ...config,
+ headers: {
+ ...config.headers,
+ Cookie: req.headers.cookie || ""
+ },
+ maxRedirects: 0
+ }
);
return respondSuccess(res, data);
} catch (error) {
diff --git a/pages/myportal/case/[ticketnumber].js b/pages/myportal/case/[ticketnumber].js
index 04859115..9a28f691 100644
--- a/pages/myportal/case/[ticketnumber].js
+++ b/pages/myportal/case/[ticketnumber].js
@@ -304,7 +304,8 @@ export const getServerSideProps = wrapper.getServerSideProps(
break;
case "myRepresentations":
const myRepresentations = await getRepsFromBlob(
- thisSession.user.id
+ thisSession.user.id,
+ { headers: { Cookie: req.headers.cookie || "" } }
);
const myRepresentationsDetails = await getDetails(
diff --git a/pages/myportal/index.js b/pages/myportal/index.js
index dfc737c9..4c0195e0 100644
--- a/pages/myportal/index.js
+++ b/pages/myportal/index.js
@@ -7,10 +7,8 @@ import pLimit from "p-limit";
import { getIP } from "../../actions/core/logger";
import { consoleLogger } from "../../actions/core/logger";
-import {
- getPersonalAccount,
- getPortalLogin
-} from "../../actions/services/accountService";
+import { getPortalLogin } from "../../actions/services/accountService";
+import { getCrmPersonalAccount } from "../../lib/server/getCrmPersonalAccount";
import {
getCase,
getPortalModuleDetails
@@ -257,9 +255,25 @@ export const getServerSideProps = wrapper.getServerSideProps(
}
await createContainerProxy(thisSession.user.id);
- const accountDetails = await getPersonalAccount(loggedInUser);
- if (accountDetails?.errorCode) {
+ let accountDetails;
+
+ try {
+ accountDetails = await getCrmPersonalAccount(loggedInUser);
+ } catch (error) {
+ consoleLogger({
+ name: "MyPortalIndexAccountLookup",
+ reasonCode: "UPSTREAM_FAILURE",
+ message: "myportal index personal account lookup failed",
+ error: error?.message
+ });
+
+ return {
+ redirect: { destination: "/myportal/error", permanent: false }
+ };
+ }
+
+ if (!accountDetails || accountDetails.errorCode) {
return {
redirect: { destination: "/myportal/error", permanent: false }
};
@@ -279,7 +293,9 @@ export const getServerSideProps = wrapper.getServerSideProps(
awaitingSubmissionFromBlob
] = await Promise.all([
isLPA ? getMyLPACases(lpaId) : getMyCases(loggedInUser),
- getRepsFromBlob(thisSession.user.id),
+ getRepsFromBlob(thisSession.user.id, {
+ headers: { Cookie: req.headers.cookie || "" }
+ }),
getWatchedCases(loggedInUser),
getAwaitingSubmissionFromBlob(thisSession.user.id)
]);
diff --git a/pages/myportal/viewall.js b/pages/myportal/viewall.js
index 46aa4cbc..4db131b6 100644
--- a/pages/myportal/viewall.js
+++ b/pages/myportal/viewall.js
@@ -14,10 +14,9 @@ import ServiceBanner from "../../components/myportal/servicebanner";
import { getSession } from "next-auth/react";
import pLimit from "p-limit";
-import {
- getPersonalAccount,
- getPortalLogin
-} from "../../actions/services/accountService";
+import { getPortalLogin } from "../../actions/services/accountService";
+import { getCrmPersonalAccount } from "../../lib/server/getCrmPersonalAccount";
+import { consoleLogger } from "../../actions/core/logger";
import { getPortalModuleDetails } from "../../actions/services/caseService";
@@ -189,7 +188,25 @@ export const getServerSideProps = wrapper.getServerSideProps(
};
}
- const accountDetails = await getPersonalAccount(loggedInUser);
+ let accountDetails;
+
+ try {
+ accountDetails = await getCrmPersonalAccount(loggedInUser);
+ } catch (error) {
+ consoleLogger({
+ name: "MyPortalViewAllAccountLookup",
+ reasonCode: "UPSTREAM_FAILURE",
+ message: "myportal viewall personal account lookup failed",
+ error: error?.message
+ });
+
+ return {
+ redirect: {
+ destination: "/myportal/error",
+ permanent: false
+ }
+ };
+ }
if (!accountDetails || accountDetails.errorCode) {
return {
@@ -354,7 +371,8 @@ export const getServerSideProps = wrapper.getServerSideProps(
case "myRepresentations": {
const myRepresentations = await getRepsFromBlob(
- thisSession.user.id
+ thisSession.user.id,
+ { headers: { Cookie: ctx.req?.headers?.cookie || "" } }
);
// The existing View All component reads
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/_shared.cjs b/tests/phase21/_shared.cjs
index 811af4e8..2903a0a2 100644
--- a/tests/phase21/_shared.cjs
+++ b/tests/phase21/_shared.cjs
@@ -34,6 +34,8 @@ const loadModule = (relativePath, injected = {}) => {
source += `\nif (typeof ${name} !== "undefined" && !module.exports.default) module.exports.default = ${name};\n`;
});
+ source +=
+ '\nif (typeof getCrmPersonalAccount !== "undefined") module.exports.getCrmPersonalAccount = getCrmPersonalAccount;\n';
source +=
'\nif (typeof respondSuccess !== "undefined") module.exports.respondSuccess = respondSuccess;\n';
source +=
@@ -48,6 +50,7 @@ const loadModule = (relativePath, injected = {}) => {
exports: {},
require,
process,
+ URL,
console: {
log: () => {},
info: () => {},
diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs
index a77a2c5b..45078ec2 100644
--- a/tests/phase21/endpoint-handler-contract.test.cjs
+++ b/tests/phase21/endpoint-handler-contract.test.cjs
@@ -9,6 +9,71 @@ const {
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
+test("getCrmPersonalAccount returns the CRM response unchanged", async () => {
+ const contactId = "726158c6-708d-ec11-aad8-00224800be9c";
+ const crmResponse = {
+ contactid: contactId,
+ firstname: "Test",
+ lastname: "User",
+ emailaddress1: "test@example.com"
+ };
+
+ let capturedQueryUrl;
+
+ const mod = loadModule("lib/server/getCrmPersonalAccount.js", {
+ relayGetData: async ({ queryUrl }) => {
+ capturedQueryUrl = queryUrl;
+ return { data: crmResponse };
+ }
+ });
+
+ const result = await mod.getCrmPersonalAccount(contactId);
+
+ assert.deepStrictEqual(result, crmResponse);
+ assert.strictEqual(
+ capturedQueryUrl,
+ "contacts(" +
+ contactId +
+ ")?$select=firstname, lastname, emailaddress1, telephone1, pinswg_companyname, address1_line1,address1_line2,address1_city, address1_county,address1_postalcode,pinswg_typeofinvolvement,pinswg_contact_associatedlpa,pinswg_preferredlanguage&$count=true"
+ );
+});
+
+test("getCrmPersonalAccount preserves the CRM lookup relay policy", async () => {
+ let capturedRelayPolicy;
+
+ const mod = loadModule("lib/server/getCrmPersonalAccount.js", {
+ relayGetData: async ({ relayPolicy }) => {
+ capturedRelayPolicy = relayPolicy;
+ return { data: {} };
+ }
+ });
+
+ await mod.getCrmPersonalAccount("726158c6-708d-ec11-aad8-00224800be9c");
+
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
+ method: "GET",
+ timeoutMs: 6000,
+ maxRetries: 1,
+ retryBaseDelayMs: 100,
+ retryMaxDelayMs: 500
+ });
+});
+
+test("getCrmPersonalAccount propagates CRM relay failures", async () => {
+ const relayError = new Error("CRM relay unavailable");
+
+ const mod = loadModule("lib/server/getCrmPersonalAccount.js", {
+ relayGetData: async () => {
+ throw relayError;
+ }
+ });
+
+ await assert.rejects(
+ () => mod.getCrmPersonalAccount("726158c6-708d-ec11-aad8-00224800be9c"),
+ (error) => error === relayError
+ );
+});
+
test("getaccounts returns EMAIL_ADDRESS_REQUIRED when emailAddress missing", async () => {
const mod = loadModule("pages/api/endpoint/getaccounts_api.js", {
respondError: respondErrorMock,
@@ -231,36 +296,16 @@ test("getpreferredlanguage success returns existing data payload contract", asyn
});
});
-test("getlogin returns EMAIL_ADDRESS_REQUIRED when emailAddress missing", async () => {
- const mod = loadModule("pages/api/endpoint/getlogin_api.js", {
+test("getportallogin returns HASH_REQUIRED when hash missing", async () => {
+ const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async () => ({}),
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
- azureHeadersPaged: () => ({}),
- axios: { get: async () => ({ data: { value: [] } }) },
- consoleLogger: () => {}
- });
-
- const req = { query: { pwd: "abc123" } };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "EMAIL_ADDRESS_REQUIRED");
-});
-
-test("getlogin returns PASSWORD_REQUIRED when pwd missing", async () => {
- const mod = loadModule("pages/api/endpoint/getlogin_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- relayGet: async () => ({}),
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
- azureHeadersPaged: () => ({}),
- axios: { get: async () => ({ data: { value: [] } }) },
- consoleLogger: () => {}
+ hashAPIPath: (input) =>
+ input && input.startsWith("/api/endpoint/getportallogin_api")
+ ? "&hash=expected"
+ : "&hash=relay",
+ azureHeadersPaged: () => ({})
});
const req = { query: { emailAddress: "user@test.local" } };
@@ -268,213 +313,48 @@ test("getlogin returns PASSWORD_REQUIRED when pwd missing", async () => {
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "PASSWORD_REQUIRED");
+ assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
});
-test("getlogin catch path returns LOGIN_FETCH_FAILED", async () => {
- const mod = loadModule("pages/api/endpoint/getlogin_api.js", {
+test("getportallogin returns INVALID_HASH when hash mismatch", async () => {
+ const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ relayGet: async () => ({}),
+ hashAPIPath: (input) =>
+ input && input.startsWith("/api/endpoint/getportallogin_api")
+ ? "&hash=expected"
+ : "&hash=relay",
+ azureHeadersPaged: () => ({})
+ });
+
+ const req = { query: { emailAddress: "user@test.local", hash: "wrong" } };
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
+});
+
+test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => {
+ const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
relayGet: async ({ res, errorResponse }) =>
respondErrorMock(res, errorResponse),
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
- azureHeadersPaged: () => ({}),
- axios: {
- get: async () => {
- throw new Error("relay failed");
- }
- },
- consoleLogger: () => {}
- });
-
- const req = { query: { emailAddress: "user@test.local", pwd: "abc123" } };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "LOGIN_FETCH_FAILED");
-});
-
-test("getlogin passes strict relayPolicy overrides to relayGet", async () => {
- let capturedRelayPolicy = null;
-
- const mod = loadModule("pages/api/endpoint/getlogin_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- relayGet: async ({ relayPolicy, res }) => {
- capturedRelayPolicy = relayPolicy;
- return respondSuccessMock(res, { value: [] });
- },
+ hashAPIPath: (input) =>
+ input && input.startsWith("/api/endpoint/getportallogin_api")
+ ? "&hash=expected"
+ : "&hash=relay",
azureHeadersPaged: () => ({})
});
- const req = { query: { emailAddress: "user@test.local", pwd: "abc123" } };
+ const req = {
+ query: { emailAddress: "user@test.local", hash: "expected" }
+ };
const res = createRes();
await mod.default(req, res);
- assert.strictEqual(res.state.statusCode, 200);
- assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
- method: "GET",
- timeoutMs: 5000,
- maxRetries: 0,
- retryBaseDelayMs: 0,
- retryMaxDelayMs: 0
- });
-});
-
-const createPortalLoginFixture = (overrides = {}) => {
- const calls = {
- session: [],
- hash: [],
- relay: []
- };
- const session = Object.prototype.hasOwnProperty.call(overrides, "session")
- ? overrides.session
- : { user: { email: "user@test.local" } };
-
- const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- getServerSession: async (...args) => {
- calls.session.push(args);
- return session;
- },
- authOptions: (req, res) => ({ req, res }),
- relayGet: async (options) => {
- calls.relay.push(options);
- if (overrides.relayGet) return overrides.relayGet(options);
- return respondSuccessMock(options.res, { value: [] });
- },
- hashAPIPath: (input) => {
- calls.hash.push(input);
- if (overrides.hashAPIPath) return overrides.hashAPIPath(input);
- return "&hash=expected";
- },
- escapeODataString: (value) => value.trim().replace(/'/g, "''"),
- azureHeadersPaged: (accessToken) => ({ accessToken })
- });
-
- return { mod, calls };
-};
-
-const invokePortalLogin = async (
- fixture,
- query = { emailAddress: "user@test.local", hash: "expected" },
- method = "GET"
-) => {
- const req = { method, query };
- const res = createRes();
- await fixture.mod.default(req, res);
-
- return { req, res };
-};
-
-test("getportallogin rejects unsupported methods before authentication", async () => {
- const fixture = createPortalLoginFixture();
- const { res } = await invokePortalLogin(fixture, undefined, "POST");
-
- 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.relay.length, 0);
-});
-
-test("getportallogin rejects missing session", async () => {
- const fixture = createPortalLoginFixture({ session: null });
- const { req, res } = await invokePortalLogin(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.relay.length, 0);
-});
-
-test("getportallogin rejects session without email", async () => {
- const fixture = createPortalLoginFixture({
- session: { user: { id: "application-user" } }
- });
- const { res } = await invokePortalLogin(fixture);
-
- assert.strictEqual(res.state.statusCode, 401);
- assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin rejects malformed requested email", async () => {
- const fixture = createPortalLoginFixture();
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "not-an-email",
- hash: "expected"
- });
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "INVALID_EMAIL_ADDRESS");
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin rejects another user's email before CRM access", async () => {
- const fixture = createPortalLoginFixture();
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "other@test.local",
- hash: "expected"
- });
-
- assert.strictEqual(res.state.statusCode, 403);
- assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
- assert.strictEqual(fixture.calls.hash.length, 0);
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin valid HMAC for another email does not bypass ownership", async () => {
- const fixture = createPortalLoginFixture({
- hashAPIPath: (input) =>
- input.includes("other%40test.local") ||
- input.includes("other@test.local")
- ? "&hash=valid-other-user-hash"
- : "&hash=unexpected"
- });
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "other@test.local",
- hash: "valid-other-user-hash"
- });
-
- assert.strictEqual(res.state.statusCode, 403);
- assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin returns HASH_REQUIRED when hash missing", async () => {
- const fixture = createPortalLoginFixture();
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "user@test.local"
- });
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin returns INVALID_HASH when hash mismatch", async () => {
- const fixture = createPortalLoginFixture();
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "user@test.local",
- hash: "wrong"
- });
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
- assert.strictEqual(fixture.calls.relay.length, 0);
-});
-
-test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => {
- const fixture = createPortalLoginFixture({
- relayGet: async ({ res, errorResponse }) =>
- respondErrorMock(res, errorResponse)
- });
- const { res } = await invokePortalLogin(fixture);
-
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
@@ -484,13 +364,26 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () =>
test("getportallogin passes strict relayPolicy overrides to relayGet", async () => {
let capturedRelayPolicy = null;
- const fixture = createPortalLoginFixture({
+
+ const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
relayGet: async ({ relayPolicy, res }) => {
capturedRelayPolicy = relayPolicy;
return respondSuccessMock(res, { value: [] });
- }
+ },
+ hashAPIPath: (input) =>
+ input && input.startsWith("/api/endpoint/getportallogin_api")
+ ? "&hash=expected"
+ : "&hash=relay",
+ azureHeadersPaged: () => ({})
});
- const { res } = await invokePortalLogin(fixture);
+
+ const req = {
+ query: { emailAddress: "user@test.local", hash: "expected" }
+ };
+ const res = createRes();
+ await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
@@ -503,65 +396,30 @@ test("getportallogin passes strict relayPolicy overrides to relayGet", async ()
});
test("getportallogin accepts encoded email hash variant", async () => {
- const fixture = createPortalLoginFixture({
- session: { user: { email: "user+test@local" } },
+ const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
relayGet: async ({ res }) =>
respondSuccessMock(res, { value: [{ contactid: "c1" }] }),
hashAPIPath: (input) =>
input && input.includes("emailAddress=user%2Btest%40local")
? "&hash=expected"
- : "&hash=other"
- });
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "user+test@local",
- hash: "expected"
+ : "&hash=other",
+ azureHeadersPaged: () => ({})
});
+ const req = {
+ query: { emailAddress: "user+test@local", hash: "expected" }
+ };
+ const res = createRes();
+ await mod.default(req, res);
+
assert.strictEqual(res.state.statusCode, 200);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
value: [{ contactid: "c1" }]
});
});
-test("getportallogin uses normalized session email and preserves CRM response", async () => {
- let capturedQueryUrl;
- const crmResponse = {
- value: [
- {
- pinswg_preferredlanguage: 846040000,
- emailaddress1: "user.o'neill@test.local",
- contactid: "contact-1",
- yomifullname: "O'Neill, User",
- firstname: "User",
- lastname: "O'Neill"
- }
- ]
- };
- const fixture = createPortalLoginFixture({
- session: { user: { email: " User.O'Neill@Test.Local " } },
- relayGet: async ({ queryUrl, res }) => {
- capturedQueryUrl = queryUrl;
- return respondSuccessMock(res, crmResponse);
- }
- });
- const { res } = await invokePortalLogin(fixture, {
- emailAddress: "user.o'neill@test.local",
- hash: "expected"
- });
-
- assert.strictEqual(res.state.statusCode, 200);
- assert.deepStrictEqual(
- JSON.parse(JSON.stringify(res.state.jsonBody)),
- crmResponse
- );
- assert.match(capturedQueryUrl, /emailaddress1 eq 'user\.o''neill@test\.local'/);
- assert.match(capturedQueryUrl, /statuscode eq 1/);
- assert.match(
- capturedQueryUrl,
- /\$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname$/
- );
-});
-
test("getnewappeals catch path returns ADMIN_NEW_APPEALS_FETCH_FAILED", async () => {
const mod = loadModule("pages/api/admin/getnewappeals_api.js", {
respondError: respondErrorMock,
@@ -1038,35 +896,75 @@ test("createaccount catch path returns ACCOUNT_CREATE_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "ACCOUNT_CREATE_FAILED");
});
-test("updateaccount returns CONTACT_ID_REQUIRED when contactId missing", async () => {
+test("updateaccount returns INVALID_CONTACT_ID 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" } }),
- consoleLogger: () => {}
+ updateCrmContact: async () => ({ contactid: "c1" }),
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+ relayGetData: async () => ({
+ data: {
+ value: [
+ {
+ contactid: "00000000-0000-0000-0000-000000000001"
+ }
+ ]
+ }
+ })
});
- const req = { query: {}, body: { firstname: "A" } };
+ const req = { method: "POST", query: {}, body: { firstname: "A" } };
const res = createRes();
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "CONTACT_ID_REQUIRED");
+ assert.strictEqual(res.state.jsonBody.error.code, "INVALID_CONTACT_ID");
});
test("updateaccount returns ACCOUNT_UPDATE_PAYLOAD_REQUIRED when body 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" } }),
- consoleLogger: () => {}
+ updateCrmContact: async () => ({ contactid: "c1" }),
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+ relayGetData: async () => ({
+ data: {
+ value: [
+ {
+ contactid: "00000000-0000-0000-0000-000000000001"
+ }
+ ]
+ }
+ })
});
- const req = { query: { contactId: "c1" }, body: undefined };
+ const req = {
+ method: "POST",
+ query: { contactId: "00000000-0000-0000-0000-000000000001" },
+ body: undefined
+ };
const res = createRes();
await mod.default(req, res);
@@ -1081,15 +979,37 @@ 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: () => {}
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+ relayGetData: async () => ({
+ data: {
+ value: [
+ {
+ contactid: "00000000-0000-0000-0000-000000000001"
+ }
+ ]
+ }
+ })
});
- const req = { query: { contactId: "c1" }, body: { firstname: "A" } };
+ const req = {
+ method: "POST",
+ query: { contactId: "00000000-0000-0000-0000-000000000001" },
+ body: { firstname: "A" }
+ };
const res = createRes();
await mod.default(req, res);
@@ -1097,66 +1017,238 @@ test("updateaccount catch path returns ACCOUNT_UPDATE_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "ACCOUNT_UPDATE_FAILED");
});
-test("updatepassword returns CONTACT_ID_REQUIRED when contactId missing", async () => {
- const mod = loadModule("pages/api/endpoint/updatepassword_api.js", {
+test("updateaccount rejects a different CRM contact before updating", async () => {
+ let updateCalls = 0;
+
+ 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" } }),
- consoleLogger: () => {}
+ consoleLogger: () => {},
+
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+
+ relayGetData: async () => ({
+ data: {
+ value: [
+ {
+ contactid: "00000000-0000-0000-0000-000000000001"
+ }
+ ]
+ }
+ }),
+
+ updateCrmContact: async () => {
+ updateCalls++;
+ return {};
+ }
});
- const req = { query: {}, body: { pinswg_password: "abc" } };
+ const req = {
+ method: "POST",
+ query: {
+ contactId: "00000000-0000-0000-0000-000000000002"
+ },
+ body: {
+ firstname: "Unauthorised Update"
+ }
+ };
+
const res = createRes();
+
await mod.default(req, res);
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "CONTACT_ID_REQUIRED");
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "CONTACT_OWNERSHIP_MISMATCH"
+ );
+ assert.strictEqual(updateCalls, 0);
});
-test("updatepassword returns PASSWORD_UPDATE_PAYLOAD_REQUIRED when body missing", async () => {
- const mod = loadModule("pages/api/endpoint/updatepassword_api.js", {
+test("updateaccount rejects unresolved CRM identity before updating", async () => {
+ let updateCalls = 0;
+
+ 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" } }),
- consoleLogger: () => {}
+ consoleLogger: () => {},
+
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+
+ relayGetData: async () => ({
+ data: {
+ value: []
+ }
+ }),
+
+ updateCrmContact: async () => {
+ updateCalls++;
+ return {};
+ }
});
- const req = { query: { contactId: "c1" }, body: undefined };
+ const req = {
+ method: "POST",
+ query: {
+ contactId: "00000000-0000-0000-0000-000000000001"
+ },
+ body: {
+ firstname: "Unauthorised Update"
+ }
+ };
+
const res = createRes();
+
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "CONTACT_IDENTITY_NOT_RESOLVED"
+ );
+ assert.strictEqual(updateCalls, 0);
+});
+
+test("updateaccount strips prohibited CRM fields before updating", async () => {
+ const contactId = "00000000-0000-0000-0000-000000000001";
+ let capturedContactId;
+ let capturedPayload;
+ let updateCalls = 0;
+
+ const mod = loadModule("pages/api/endpoint/updateaccount_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ consoleLogger: () => {},
+
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+
+ relayGetData: async () => ({
+ data: {
+ value: [{ contactid: contactId }]
+ }
+ }),
+
+ updateCrmContact: async (id, payload) => {
+ updateCalls++;
+ capturedContactId = id;
+ capturedPayload = payload;
+ return { contactid: id };
+ }
+ });
+
+ const req = {
+ method: "POST",
+ query: { contactId },
+ body: {
+ firstname: "Robert",
+ telephone1: "01234567890",
+ emailaddress1: "attacker@example.com",
+ statuscode: 2,
+ contactid: "00000000-0000-0000-0000-000000000002"
+ }
+ };
+
+ const res = createRes();
+
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(updateCalls, 1);
+ assert.strictEqual(capturedContactId, contactId);
+
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedPayload)), {
+ firstname: "Robert",
+ telephone1: "01234567890"
+ });
+});
+
+test("updateaccount rejects payload containing only prohibited fields", async () => {
+ const contactId = "00000000-0000-0000-0000-000000000001";
+ let updateCalls = 0;
+
+ const mod = loadModule("pages/api/endpoint/updateaccount_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ consoleLogger: () => {},
+
+ getServerSession: async () => ({
+ user: { email: "owner@example.com" }
+ }),
+ authOptions: () => ({}),
+
+ getToken: async () => ({
+ access_token: "test-token"
+ }),
+ escapeODataString: (value) => value,
+ azureHeaders: (token) => ({
+ headers: { Authorization: `Bearer ${token}` }
+ }),
+
+ relayGetData: async () => ({
+ data: {
+ value: [{ contactid: contactId }]
+ }
+ }),
+
+ updateCrmContact: async () => {
+ updateCalls++;
+ return {};
+ }
+ });
+
+ const req = {
+ method: "POST",
+ query: { contactId },
+ body: {
+ emailaddress1: "attacker@example.com",
+ statuscode: 2,
+ contactid: "00000000-0000-0000-0000-000000000002"
+ }
+ };
+
+ const res = createRes();
+
await mod.default(req, res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
- "PASSWORD_UPDATE_PAYLOAD_REQUIRED"
+ "NO_ALLOWED_ACCOUNT_FIELDS"
);
-});
-
-test("updatepassword catch path returns PASSWORD_UPDATE_FAILED", async () => {
- const mod = loadModule("pages/api/endpoint/updatepassword_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
- axios: async () => {
- throw new Error("relay failed");
- },
- consoleLogger: () => {}
- });
-
- const req = {
- query: { contactId: "c1" },
- body: { pinswg_password: "abc" }
- };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "PASSWORD_UPDATE_FAILED");
+ assert.strictEqual(updateCalls, 0);
});
test("getbasicsearch returns SEARCH_STRING_REQUIRED when searchString missing", async () => {
@@ -1330,7 +1422,7 @@ test("getadvancedsearchpaged returns SEARCH_STRING_REQUIRED when searchstring mi
respondSuccess: respondSuccessMock,
relayGet: async () => ({}),
azureHeadersPagedCustom: () => ({}),
- _: { has: () => false }
+ hasOwn: (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
});
const req = {
@@ -1353,7 +1445,7 @@ test("getadvancedsearchpaged returns ORDER_BY_REQUIRED when orderby missing", as
respondSuccess: respondSuccessMock,
relayGet: async () => ({}),
azureHeadersPagedCustom: () => ({}),
- _: { has: () => false }
+ hasOwn: (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
});
const req = {
@@ -1377,9 +1469,7 @@ test("getadvancedsearchpaged catch path returns ADVANCED_SEARCH_PAGED_FETCH_FAIL
relayGet: async ({ res, errorResponse }) =>
respondErrorMock(res, errorResponse),
azureHeadersPagedCustom: () => ({}),
- hasOwn: (obj, key) =>
- obj != null && Object.prototype.hasOwnProperty.call(obj, key),
- _: { has: () => false }
+ hasOwn: (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
});
const req = {
@@ -3029,10 +3119,16 @@ test("createwatchedcases returns WATCHED_CASE_BINDINGS_REQUIRED when bindings mi
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=expected",
axios: async () => ({ data: {} }),
- consoleLogger: () => {}
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: {
+ email: "test@example.com"
+ }
+ }),
+ authOptions: () => ({})
});
- const req = { body: {} };
+ const req = { method: "POST", body: {} };
const res = createRes();
await mod.default(req, res);
@@ -3050,10 +3146,17 @@ test("createwatchedcases returns INVALID_BINDING_FORMAT when bindings invalid",
getToken: async () => ({ access_token: "token" }),
hashAPIPath: () => "&hash=expected",
axios: async () => ({ data: {} }),
- consoleLogger: () => {}
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: {
+ email: "test@example.com"
+ }
+ }),
+ authOptions: () => ({})
});
const req = {
+ method: "POST",
body: {
"pinswg_WatchedCase@odata.bind": "/incidents",
"pinswg_Contact@odata.bind": "/contacts"
@@ -3075,13 +3178,22 @@ test("createwatchedcases catch path returns WATCHED_CASE_UPSERT_FAILED", async (
},
hashAPIPath: () => "&hash=expected",
axios: async () => ({ data: {} }),
- consoleLogger: () => {}
+ consoleLogger: () => {},
+ getServerSession: async () => ({
+ user: {
+ email: "test@example.com"
+ }
+ }),
+ authOptions: () => ({})
});
const req = {
+ method: "POST",
body: {
- "pinswg_WatchedCase@odata.bind": "/incidents(incident-id)",
- "pinswg_Contact@odata.bind": "/contacts(contact-id)"
+ "pinswg_WatchedCase@odata.bind":
+ "/incidents(11111111-1111-1111-1111-111111111111)",
+ "pinswg_Contact@odata.bind":
+ "/contacts(22222222-2222-2222-2222-222222222222)"
}
};
const res = createRes();
@@ -3094,6 +3206,561 @@ test("createwatchedcases catch path returns WATCHED_CASE_UPSERT_FAILED", async (
);
});
+test("createwatchedcases rejects unauthenticated requests before CRM access", async () => {
+ let tokenCalls = 0;
+ let relayCalls = 0;
+ let writeCalls = 0;
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => null,
+ authOptions: () => ({}),
+ getToken: async () => {
+ tokenCalls += 1;
+ return { access_token: "token" };
+ },
+ relayGetData: async () => {
+ relayCalls += 1;
+ return { data: { value: [] } };
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async () => {
+ writeCalls += 1;
+ return { data: {} };
+ },
+ consoleLogger: () => {},
+ authOptions: () => ({})
+ });
+
+ const req = {
+ method: "POST",
+ body: {
+ "pinswg_WatchedCase@odata.bind":
+ "/incidents(11111111-1111-1111-1111-111111111111)",
+ "pinswg_Contact@odata.bind":
+ "/contacts(22222222-2222-2222-2222-222222222222)"
+ }
+ };
+
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 401);
+ assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
+ assert.strictEqual(tokenCalls, 0);
+ assert.strictEqual(relayCalls, 0);
+ assert.strictEqual(writeCalls, 0);
+});
+
+test("createwatchedcases rejects another contact before CRM write", async () => {
+ let writeCalls = 0;
+
+ const ownContactID = "22222222-2222-2222-2222-222222222222";
+ const otherContactID = "33333333-3333-3333-3333-333333333333";
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: {
+ value: [{ contactid: ownContactID }]
+ }
+ };
+ }
+
+ return { data: { value: [] } };
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async () => {
+ writeCalls += 1;
+ return { data: {} };
+ },
+ consoleLogger: () => {}
+ });
+
+ const req = {
+ method: "POST",
+ body: {
+ "pinswg_WatchedCase@odata.bind":
+ "/incidents(11111111-1111-1111-1111-111111111111)",
+ "pinswg_Contact@odata.bind": `/contacts(${otherContactID})`
+ }
+ };
+
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "CONTACT_OWNERSHIP_MISMATCH"
+ );
+ assert.strictEqual(writeCalls, 0);
+});
+
+test("createwatchedcases rejects malformed GUID bindings before CRM access", async () => {
+ let tokenCalls = 0;
+ let relayCalls = 0;
+ let writeCalls = 0;
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => {
+ tokenCalls += 1;
+ return { access_token: "token" };
+ },
+ relayGetData: async () => {
+ relayCalls += 1;
+ return { data: { value: [] } };
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async () => {
+ writeCalls += 1;
+ return { data: {} };
+ },
+ consoleLogger: () => {}
+ });
+
+ const req = {
+ method: "POST",
+ body: {
+ "pinswg_WatchedCase@odata.bind": "/incidents(not-a-valid-guid)",
+ "pinswg_Contact@odata.bind":
+ "/contacts(22222222-2222-2222-2222-222222222222)"
+ }
+ };
+
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(res.state.jsonBody.error.code, "INVALID_BINDING_FORMAT");
+ assert.strictEqual(tokenCalls, 0);
+ assert.strictEqual(relayCalls, 0);
+ assert.strictEqual(writeCalls, 0);
+});
+
+test("createwatchedcases rejects existing watchlist with mismatched ownership", async () => {
+ let writeCalls = 0;
+
+ const ownContactID = "22222222-2222-2222-2222-222222222222";
+ const otherContactID = "33333333-3333-3333-3333-333333333333";
+ const caseID = "11111111-1111-1111-1111-111111111111";
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: { value: [{ contactid: ownContactID }] }
+ };
+ }
+
+ if (queryUrl.startsWith("pinswg_watchlists?")) {
+ return {
+ data: {
+ value: [
+ {
+ pinswg_watchlistid:
+ "44444444-4444-4444-4444-444444444444",
+ _pinswg_contact_value: otherContactID,
+ _pinswg_watchedcase_value: caseID
+ }
+ ]
+ }
+ };
+ }
+
+ throw new Error("Unexpected CRM query");
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async () => {
+ writeCalls += 1;
+ return { data: {} };
+ },
+ consoleLogger: () => {}
+ });
+
+ const req = {
+ method: "POST",
+ body: {
+ "pinswg_WatchedCase@odata.bind": `/incidents(${caseID})`,
+ "pinswg_Contact@odata.bind": `/contacts(${ownContactID})`
+ }
+ };
+
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "WATCHLIST_OWNERSHIP_MISMATCH"
+ );
+ assert.strictEqual(writeCalls, 0);
+});
+
+test("createwatchedcases allows multiple existing watchlists for the same contact and case", async () => {
+ const writes = [];
+
+ const contactID = "22222222-2222-2222-2222-222222222222";
+ const caseID = "11111111-1111-1111-1111-111111111111";
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: { value: [{ contactid: contactID }] }
+ };
+ }
+
+ if (queryUrl.startsWith("pinswg_watchlists?")) {
+ return {
+ data: {
+ value: [
+ {
+ pinswg_watchlistid:
+ "44444444-4444-4444-4444-444444444444",
+ _pinswg_contact_value: contactID,
+ _pinswg_watchedcase_value: caseID
+ },
+ {
+ pinswg_watchlistid:
+ "55555555-5555-5555-5555-555555555555",
+ _pinswg_contact_value: contactID,
+ _pinswg_watchedcase_value: caseID
+ }
+ ]
+ }
+ };
+ }
+
+ throw new Error("Unexpected CRM query");
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async (config) => {
+ writes.push(config);
+ return { data: {} };
+ },
+ consoleLogger: () => {}
+ });
+
+ const req = {
+ method: "POST",
+ body: {
+ "pinswg_WatchedCase@odata.bind": `/incidents(${caseID})`,
+ "pinswg_Contact@odata.bind": `/contacts(${contactID})`
+ }
+ };
+
+ const res = createRes();
+ await mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(writes.length, 1);
+ assert.strictEqual(writes[0].method, "patch");
+
+ assert.ok(
+ writes[0].url.includes(
+ "pinswg_watchlists(44444444-4444-4444-4444-444444444444)"
+ )
+ );
+});
+
+test("createwatchedcases creates a watched case for the authenticated contact", async () => {
+ const contactID = "22222222-2222-2222-2222-222222222222";
+ const caseID = "11111111-1111-1111-1111-111111111111";
+ const writes = [];
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: { value: [{ contactid: contactID }] }
+ };
+ }
+
+ if (queryUrl.startsWith("pinswg_watchlists?")) {
+ return { data: { value: [] } };
+ }
+
+ throw new Error("Unexpected CRM query");
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async (config) => {
+ writes.push(config);
+ return { data: { created: true } };
+ },
+ consoleLogger: () => {}
+ });
+
+ const body = {
+ "pinswg_WatchedCase@odata.bind": `/incidents(${caseID})`,
+ "pinswg_Contact@odata.bind": `/contacts(${contactID})`,
+ pinswg_emailnotifications: true
+ };
+
+ const res = createRes();
+
+ await mod.default(
+ {
+ method: "POST",
+ body
+ },
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(writes.length, 1);
+ assert.strictEqual(writes[0].method, "post");
+ assert.ok(writes[0].url.includes("pinswg_watchlists"));
+
+ const writtenData = JSON.parse(writes[0].data);
+
+ assert.strictEqual(
+ writtenData["pinswg_Contact@odata.bind"],
+ `/contacts(${contactID})`
+ );
+ assert.strictEqual(
+ writtenData["pinswg_WatchedCase@odata.bind"],
+ `/incidents(${caseID})`
+ );
+ assert.strictEqual(writtenData.pinswg_emailnotifications, true);
+});
+
+test("createwatchedcases updates a verified watchlist preserving representation fields", async () => {
+ const contactID = "22222222-2222-2222-2222-222222222222";
+ const caseID = "11111111-1111-1111-1111-111111111111";
+ const watchlistID = "44444444-4444-4444-4444-444444444444";
+ const writes = [];
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: { value: [{ contactid: contactID }] }
+ };
+ }
+
+ if (queryUrl.startsWith("pinswg_watchlists?")) {
+ return {
+ data: {
+ value: [
+ {
+ pinswg_watchlistid: watchlistID,
+ _pinswg_contact_value: contactID,
+ _pinswg_watchedcase_value: caseID
+ }
+ ]
+ }
+ };
+ }
+
+ throw new Error("Unexpected CRM query");
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async (config) => {
+ writes.push(config);
+ return { data: { updated: true } };
+ },
+ consoleLogger: () => {}
+ });
+
+ const body = {
+ "pinswg_WatchedCase@odata.bind": `/incidents(${caseID})`,
+ "pinswg_Contact@odata.bind": `/contacts(${contactID})`,
+ pinswg_emailnotifications: true,
+ pinswg_representationsubmitted: true,
+ pinswg_representationtype: 1
+ };
+
+ const res = createRes();
+
+ await mod.default({ method: "POST", body }, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(writes.length, 1);
+ assert.strictEqual(writes[0].method, "patch");
+ assert.ok(writes[0].url.includes(`pinswg_watchlists(${watchlistID})`));
+
+ const writtenData = JSON.parse(writes[0].data);
+
+ assert.strictEqual(writtenData.pinswg_emailnotifications, true);
+ assert.strictEqual(writtenData.pinswg_representationsubmitted, true);
+ assert.strictEqual(writtenData.pinswg_representationtype, 1);
+ assert.strictEqual(
+ writtenData["pinswg_Contact@odata.bind"],
+ `/contacts(${contactID})`
+ );
+});
+
+test("createwatchedcases preserves previous representations when submitting another", async () => {
+ const contactID = "22222222-2222-2222-2222-222222222222";
+ const caseID = "11111111-1111-1111-1111-111111111111";
+ const writes = [];
+
+ const existingRecords = [
+ {
+ pinswg_watchlistid: "44444444-4444-4444-4444-444444444444",
+ _pinswg_contact_value: contactID,
+ _pinswg_watchedcase_value: caseID,
+ pinswg_representationsubmitted: "2025-01-07T10:00:00Z",
+ pinswg_representationtype: 1
+ },
+ {
+ pinswg_watchlistid: "55555555-5555-5555-5555-555555555555",
+ _pinswg_contact_value: contactID,
+ _pinswg_watchedcase_value: caseID,
+ pinswg_representationsubmitted: "2025-01-09T14:00:00Z",
+ pinswg_representationtype: 2
+ }
+ ];
+
+ const mod = loadModule("pages/api/endpoint/createwatchedcases_api.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ getServerSession: async () => ({
+ user: { email: "test@example.com" }
+ }),
+ authOptions: () => ({}),
+ getToken: async () => ({ access_token: "token" }),
+ azureHeaders: () => ({
+ headers: { Authorization: "Bearer token" }
+ }),
+ escapeODataString: (value) => value.replace(/'/g, "''"),
+ relayGetData: async ({ queryUrl }) => {
+ if (queryUrl.startsWith("contacts?")) {
+ return {
+ data: { value: [{ contactid: contactID }] }
+ };
+ }
+
+ if (queryUrl.startsWith("pinswg_watchlists?")) {
+ assert.ok(queryUrl.includes("pinswg_representationsubmitted"));
+
+ return {
+ data: { value: existingRecords }
+ };
+ }
+
+ throw new Error("Unexpected CRM query");
+ },
+ hashAPIPath: () => "&hash=expected",
+ axios: async (config) => {
+ writes.push(config);
+ return { data: { created: true } };
+ },
+ consoleLogger: () => {}
+ });
+
+ const submissionDate = "2025-01-14T09:30:00Z";
+
+ const body = {
+ "pinswg_WatchedCase@odata.bind": `/incidents(${caseID})`,
+ "pinswg_Contact@odata.bind": `/contacts(${contactID})`,
+ pinswg_representationsubmitted: submissionDate,
+ pinswg_representationtype: 3
+ };
+
+ const res = createRes();
+
+ await mod.default({ method: "POST", body }, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(writes.length, 1);
+
+ // A new submission must not PATCH a historical representation.
+ assert.strictEqual(writes[0].method, "post");
+ assert.ok(writes[0].url.includes("pinswg_watchlists"));
+ assert.ok(
+ !writes[0].url.includes(
+ "pinswg_watchlists(44444444-4444-4444-4444-444444444444)"
+ )
+ );
+
+ const writtenData = JSON.parse(writes[0].data);
+
+ assert.strictEqual(
+ writtenData.pinswg_representationsubmitted,
+ submissionDate
+ );
+ assert.strictEqual(writtenData.pinswg_representationtype, 3);
+ assert.strictEqual(
+ writtenData["pinswg_Contact@odata.bind"],
+ `/contacts(${contactID})`
+ );
+
+ // Neither historical submission was modified.
+ assert.strictEqual(
+ existingRecords[0].pinswg_representationsubmitted,
+ "2025-01-07T10:00:00Z"
+ );
+ assert.strictEqual(
+ existingRecords[1].pinswg_representationsubmitted,
+ "2025-01-09T14:00:00Z"
+ );
+});
+
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";
@@ -3111,7 +3778,10 @@ const createDeleteWatchedCasesFixture = (overrides = {}) => {
const contacts = Object.prototype.hasOwnProperty.call(overrides, "contacts")
? overrides.contacts
: [{ contactid: CONTACT_ID }];
- const watchlist = Object.prototype.hasOwnProperty.call(overrides, "watchlist")
+ const watchlist = Object.prototype.hasOwnProperty.call(
+ overrides,
+ "watchlist"
+ )
? overrides.watchlist
: {
pinswg_watchlistid: WATCHED_CASE_ID,
@@ -3177,7 +3847,10 @@ test("deletewatchedcases rejects missing watchedCaseID without CRM access", asyn
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(
+ 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);
@@ -4108,76 +4781,6 @@ test("getappealpdfdocuments catch path returns APPEAL_PDF_DOCUMENTS_FETCH_FAILED
);
});
-test("createcase returns CASE_PAYLOAD_REQUIRED when body missing", async () => {
- const mod = loadModule("pages/api/endpoint/createcase_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- getToken: async () => ({ access_token: "token" }),
- getCaseBlob: async () => {},
- hashAPIPath: () => "&hash=expected",
- axios: async () => ({ data: { ticketnumber: "CAS-1" } }),
- consoleLogger: () => {}
- });
-
- const req = { query: {}, body: undefined };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "CASE_PAYLOAD_REQUIRED");
-});
-
-test("createcase returns CONTACT_ID_REQUIRED when contactid missing", async () => {
- const mod = loadModule("pages/api/endpoint/createcase_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- getToken: async () => ({ access_token: "token" }),
- getCaseBlob: async () => {},
- hashAPIPath: () => "&hash=expected",
- axios: async () => ({ data: { ticketnumber: "CAS-1" } }),
- consoleLogger: () => {}
- });
-
- const req = {
- query: { appealTypeId: "a1", containername: "c1", lpaID: "l1" },
- body: { title: "x" }
- };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "CONTACT_ID_REQUIRED");
-});
-
-test("createcase catch path returns CASE_CREATE_FAILED", async () => {
- const mod = loadModule("pages/api/endpoint/createcase_api.js", {
- respondError: respondErrorMock,
- respondSuccess: respondSuccessMock,
- getToken: async () => ({ access_token: "token" }),
- getCaseBlob: async () => {},
- hashAPIPath: () => "&hash=expected",
- axios: async () => {
- throw new Error("create failed");
- },
- consoleLogger: () => {}
- });
-
- const req = {
- query: {
- contactid: "c1",
- appealTypeId: "a1",
- containername: "container",
- lpaID: "l1"
- },
- body: { title: "x" }
- };
- const res = createRes();
- await mod.default(req, res);
-
- assert.strictEqual(res.state.statusCode, 400);
- assert.strictEqual(res.state.jsonBody.error.code, "CASE_CREATE_FAILED");
-});
-
test("updatecase returns CASE_UPDATE_PAYLOAD_REQUIRED when body missing", async () => {
const mod = loadModule("pages/api/endpoint/updatecase_api.js", {
respondError: respondErrorMock,
@@ -4481,11 +5084,20 @@ test("getToken catch path returns TOKEN_FETCH_FAILED", async () => {
test("getsipsmedia returns success contract", async () => {
const mod = loadModule("pages/api/endpoint/getsipsmedia_api.js", {
+ respondError: respondErrorMock,
relayGet: async ({ res }) =>
- respondSuccessMock(res, { "@odata.count": 4 })
+ respondSuccessMock(res, {
+ "@odata.count": 4,
+ value: []
+ })
});
- const req = { query: { caseid: "case-1" } };
+ const req = {
+ method: "GET",
+ query: {
+ caseid: "11111111-1111-4111-8111-111111111111"
+ }
+ };
const res = createRes();
await mod.default(req, res);
@@ -4524,13 +5136,25 @@ test("getappealtypesfornewappeal returns METHOD_NOT_ALLOWED for non-GET methods"
});
const run = async () => {
+ const filter = process.env.PEDW_TEST_FILTER;
+
+ const selectedTests = filter
+ ? tests.filter((currentTest) => currentTest.name.includes(filter))
+ : tests;
+
+ if (selectedTests.length === 0) {
+ throw new Error(`No tests match filter: ${filter}`);
+ }
+
let passed = 0;
- for (const currentTest of tests) {
+
+ for (const currentTest of selectedTests) {
await currentTest.fn();
passed += 1;
}
+
console.log(
- `Phase 21 endpoint-handler contract tests passed (${passed}/${tests.length}).`
+ `Phase 21 endpoint-handler contract tests passed (${passed}/${selectedTests.length}).`
);
};
diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs
index 863fd67a..53fcc5cc 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,
@@ -1545,6 +1554,10 @@ test("getrepsblob handler success returns representations payload", async () =>
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
+ getServerSession: async () => ({ user: { id: "c1" } }),
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
getRepsBlobs: async () => [
{ path: "c/ref_rep.json", name: "ref_rep.json" }
],
@@ -1603,6 +1616,10 @@ test("getrepsblob handler rejects invalid HMAC before Azure access", async () =>
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
+ getServerSession: async () => ({ user: { id: "c1" } }),
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
getRepsBlobs: async () => {
azureCalls += 1;
return [];
@@ -1692,7 +1709,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 +1727,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);
@@ -1801,6 +1825,10 @@ test("getrepsblob handler dependency failure returns GET_REPS_BLOB_FAILED", asyn
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
+ getServerSession: async () => ({ user: { id: "c1" } }),
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
getRepsBlobs: async () => {
throw new Error("storage failure");
},
@@ -1828,6 +1856,10 @@ test("getrepsblob handler accepts encoded container hash variant", async () => {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
consoleLogger: () => {},
+ getServerSession: async () => ({ user: { id: "cont/1" } }),
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
getRepsBlobs: async () => [{ path: "cont/rep1.json" }],
downloadAllRepsFiles: async () => ({ value: [{ id: "r-encoded" }] })
});
@@ -2256,14 +2288,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 +2319,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 +2464,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 +2549,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 +2562,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 +2592,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);
@@ -2825,7 +2885,13 @@ const loadOrdinaryRepsProxy = (overrides = {}) => {
return "&hash=abc";
},
consoleLogger: () => {},
- process: { env: { API_ROOT: "http://example.com" } },
+ BASE_URL: "http://example.com",
+ process: {
+ env: {
+ API_ROOT: "http://example.com",
+ NEXTAUTH_URL: "http://example.com"
+ }
+ },
axios: {
get: async (...args) => {
calls.axios.push(args);
@@ -2935,7 +3001,9 @@ for (const containerCase of [
test("getrepsblobproxy preserves signed path and response for owning user", async () => {
const loaded = loadOrdinaryRepsProxy();
- const req = ordinaryRepsRequest();
+ const req = ordinaryRepsRequest({
+ headers: { cookie: "next-auth.session-token=test-session" }
+ });
const res = createRes();
await loaded.mod.default(req, res);
@@ -2951,6 +3019,16 @@ test("getrepsblobproxy preserves signed path and response for owning user", asyn
loaded.calls.axios[0][0],
"http://example.com/api/file/getrepsblob?container=user-container&hash=abc"
);
+ assert.deepStrictEqual(
+ JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
+ {
+ headers: {
+ Authorization: "Bearer tok",
+ Cookie: "next-auth.session-token=test-session"
+ },
+ maxRedirects: 0
+ }
+ );
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
@@ -2986,6 +3064,7 @@ const loadAdminRepsProxy = (overrides = {}) => {
const mod = loadModule("pages/api/admin/getrepsblobproxy.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ ADMIN_SESSION_COOKIE: "pedw_admin_session",
readAdminSessionCookie: () => "admin-token",
validateAdminSession: () => true,
hasTrustedSameOrigin: () => true,
@@ -3000,7 +3079,13 @@ const loadAdminRepsProxy = (overrides = {}) => {
return "&hash=abc";
},
consoleLogger: () => {},
- process: { env: { API_ROOT: "http://example.com" } },
+ BASE_URL: "http://example.com",
+ process: {
+ env: {
+ API_ROOT: "http://example.com",
+ NEXTAUTH_URL: "http://example.com"
+ }
+ },
axios: {
get: async (...args) => {
calls.axios.push(args);
@@ -3051,7 +3136,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") {
@@ -3125,6 +3212,16 @@ test("admin getrepsblobproxy preserves cross-container signed path and response"
loaded.calls.axios[0][0],
"http://example.com/api/file/getrepsblob?container=selected-user-container&hash=abc"
);
+ assert.deepStrictEqual(
+ JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
+ {
+ headers: {
+ Authorization: "Bearer tok",
+ Cookie: "pedw_admin_session=admin-token"
+ },
+ maxRedirects: 0
+ }
+ );
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
loaded.payload
@@ -3156,6 +3253,19 @@ test("admin storage refresh uses dedicated POST proxy while ordinary callers rem
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
});
+test("admin getrepsblobproxy rejects untrusted internal destination before proxy work", async () => {
+ const loaded = loadAdminRepsProxy({
+ BASE_URL: "https://attacker.example"
+ });
+ const res = createRes();
+
+ await loaded.mod.default(adminRepsRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assertNoProxyWork(loaded.calls);
+});
+
test("admin storage completion uses dedicated POST route while ordinary completion remains unchanged", () => {
const rootDir = path.resolve(__dirname, "..", "..");
const adminSource = fs.readFileSync(
@@ -3563,53 +3673,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 +3763,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 +3917,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 +3927,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 +4015,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 +5097,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 +5112,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;
@@ -5064,27 +5178,23 @@ for (const containerCase of [
});
}
-test("generatepdf rejects missing and conflicting draft-folder identifiers", async () => {
- for (const body of [
- {
- representationType: "Other",
- repfile_name: "rep-file",
- containerID: PDF_OWNER_CONTAINER
- },
- {
- representationType: "Other",
- repfile_name: "rep-file",
- containerID: PDF_OWNER_CONTAINER,
- ticketnumber: PDF_CASE_FOLDER,
- caseRef: "CASE-2"
- }
- ]) {
- const loaded = loadGeneratePdf();
- const res = createRes();
- await loaded.mod.default(generatePdfRequest({ body }), res);
- assert.strictEqual(res.state.statusCode, 400);
- assertNoGeneratePdfWork(loaded.calls);
- }
+test("generatepdf rejects missing draft-folder identifiers", async () => {
+ const loaded = loadGeneratePdf();
+ const res = createRes();
+
+ await loaded.mod.default(
+ generatePdfRequest({
+ body: {
+ representationType: "Other",
+ repfile_name: "rep-file",
+ containerID: PDF_OWNER_CONTAINER
+ }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assertNoGeneratePdfWork(loaded.calls);
});
test("generateappealpdf rejects missing or malformed draft-folder identifiers", async () => {
@@ -5150,7 +5260,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);
@@ -5174,6 +5286,51 @@ test("generatepdf owner success writes the PDF to the authorised container", asy
assert.strictEqual(res.state.jsonBody.status, "success");
});
+test("generatepdf preserves ticketnumber precedence when references differ", async () => {
+ const loaded = loadGeneratePdf();
+ const res = createRes();
+
+ await loaded.mod.default(
+ generatePdfRequest({
+ body: {
+ representationType: "Other",
+ repfile_name: "rep-file",
+ containerID: PDF_OWNER_CONTAINER,
+ ticketnumber: PDF_CASE_FOLDER,
+ caseRef: "CASE-2"
+ }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.calls.create.length, 1);
+ assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER);
+ assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER);
+ assert.strictEqual(res.state.jsonBody.status, "success");
+});
+
+test("generatepdf rejects malformed secondary case references", async () => {
+ const loaded = loadGeneratePdf();
+ const res = createRes();
+
+ await loaded.mod.default(
+ generatePdfRequest({
+ body: {
+ representationType: "Other",
+ repfile_name: "rep-file",
+ containerID: PDF_OWNER_CONTAINER,
+ ticketnumber: PDF_CASE_FOLDER,
+ caseRef: "../other-case"
+ }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assertNoGeneratePdfWork(loaded.calls);
+});
+
test("generateappealpdf owner success reads and writes only the authorised container", async () => {
const loaded = loadGenerateAppealPdf();
const res = createRes();
@@ -5457,10 +5614,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])),
@@ -5469,10 +5623,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,
@@ -5499,6 +5655,75 @@ test("createcase_api preserves the existing propagated token dependency failure
assert.strictEqual(loaded.calls.appealBlob.length, 0);
});
+const loadRepresentationDeletion = (route, operationName, session) => {
+ const calls = [];
+ const mod = loadModule(route, {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ getServerSession: async () => session,
+ authOptions: () => ({}),
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ [operationName]: async (...args) => {
+ calls.push(args);
+ return { deleted: true };
+ }
+ });
+
+ return { mod, calls };
+};
+
+test("whole-representation deletion remains cross-user protected with zero Azure operations", async () => {
+ const loaded = loadRepresentationDeletion(
+ "pages/api/file/deleteblobrep.js",
+ "deleteBlobRep",
+ { user: { id: "different-container" } }
+ );
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ {
+ query: {
+ container: "organisation-container",
+ casefolderID: "CASE-1",
+ repfile: "REP-1",
+ hash: "expected"
+ }
+ },
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assert.strictEqual(loaded.calls.length, 0);
+});
+
+test("representation supporting-file deletion remains cross-user protected with zero Azure operations", async () => {
+ const loaded = loadRepresentationDeletion(
+ "pages/api/file/deleteblob.js",
+ "deleteBlob",
+ { user: { id: "different-container" } }
+ );
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ {
+ query: {
+ container: "organisation-container",
+ casefolderID: "CASE-1/REP-1",
+ blobname: "evidence.pdf",
+ hash: "expected"
+ }
+ },
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assert.strictEqual(loaded.calls.length, 0);
+});
+
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
diff --git a/tests/phase22/api-grouping-facade-myportal-reads.test.cjs b/tests/phase22/api-grouping-facade-myportal-reads.test.cjs
index a9464293..0f334d5b 100644
--- a/tests/phase22/api-grouping-facade-myportal-reads.test.cjs
+++ b/tests/phase22/api-grouping-facade-myportal-reads.test.cjs
@@ -129,7 +129,7 @@ test("excluded route families remain outside this myportal slice", async () => {
const pageSource = read("pages/myportal/index.js");
assert.match(portalSource, /\/api\/subscriptions\/get-watched-cases/);
- assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id\)/);
+ assert.match(pageSource, /getRepsFromBlob\(thisSession.user.id,\s*\{/);
assert.match(
pageSource,
/getAwaitingSubmissionFromBlob\(thisSession.user.id\)/
diff --git a/tests/phase22/auth-redirect-safety.test.cjs b/tests/phase22/auth-redirect-safety.test.cjs
index 0a194b5c..4416c311 100644
--- a/tests/phase22/auth-redirect-safety.test.cjs
+++ b/tests/phase22/auth-redirect-safety.test.cjs
@@ -6,10 +6,7 @@ const vm = require("vm");
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
-const loadAuthInternals = ({
- adapterFactory = () => ({}),
- resolveCrmLocale = async () => null
-} = {}) => {
+const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => {
const filePath = path.join(
__dirname,
"..",
@@ -59,7 +56,7 @@ const loadAuthInternals = ({
},
NextAuth: () => ({}),
EmailProvider: () => ({}),
- resolveCrmLocale,
+ getPortalLogin: async () => ({ value: [] }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
diff --git a/tests/phase22/breadcrumbs-route-map-structure.test.cjs b/tests/phase22/breadcrumbs-route-map-structure.test.cjs
index 4dfd31a3..f416dca4 100644
--- a/tests/phase22/breadcrumbs-route-map-structure.test.cjs
+++ b/tests/phase22/breadcrumbs-route-map-structure.test.cjs
@@ -203,12 +203,6 @@ test("breadcrumbs/factory module includes expected mapped route definitions", as
true,
"Expected /account/personaldetails to be mapped in createDetailAndAccountRouteRenderers"
);
-
- assert.strictEqual(
- source.includes('"/account/changepassword": () =>'),
- true,
- "Expected /account/changepassword to be mapped in createDetailAndAccountRouteRenderers"
- );
});
test("breadcrumbs/component composes mapped routes via imported factories and shared resolver", async () => {
diff --git a/tests/phase22/myportal-index-loader-guards.test.cjs b/tests/phase22/myportal-index-loader-guards.test.cjs
new file mode 100644
index 00000000..1cbc4ef9
--- /dev/null
+++ b/tests/phase22/myportal-index-loader-guards.test.cjs
@@ -0,0 +1,247 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+
+const tests = [];
+const test = (name, fn) => tests.push({ name, fn });
+
+const loadIndexLoader = (overrides = {}) => {
+ const filePath = path.join(
+ __dirname,
+ "..",
+ "..",
+ "pages",
+ "myportal",
+ "index.js"
+ );
+
+ let source = fs.readFileSync(filePath, "utf8");
+
+ // Execute only the server-side loader, not the React component.
+ source = source.slice(source.indexOf("export const getServerSideProps"));
+ source = source.slice(0, source.indexOf("const mapStateToProps"));
+
+ source = source.replace(
+ /export\s+const\s+getServerSideProps\s*=/,
+ "const getServerSideProps ="
+ );
+ source += "\nmodule.exports = { getServerSideProps };\n";
+
+ const dispatches = [];
+ const store = {
+ dispatch: (action) => dispatches.push(action)
+ };
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ process: { env: {} },
+ console: { error: () => {} },
+ wrapper: {
+ getServerSideProps: (factory) => async (ctx) => factory(store)(ctx)
+ },
+ getSession: async () => ({
+ user: {
+ id: "session-user-1",
+ email: "user@example.com"
+ }
+ }),
+ getIP: () => {},
+ consoleLogger: () => {},
+ getPortalLogin: async () => ({
+ value: [{ contactid: "crm-contact-1" }]
+ }),
+ createContainerProxy: async () => {},
+ getCrmPersonalAccount: async () => ({
+ firstname: "Test",
+ pinswg_typeofinvolvement: 0,
+ pinswg_preferredlanguage: 0
+ }),
+ getMyCases: async () => ({ value: [] }),
+ getMyLPACases: async () => ({ value: [] }),
+ getRepsFromBlob: async () => ({ value: [] }),
+ getWatchedCases: async () => ({ value: [] }),
+ getAwaitingSubmissionFromBlob: async () => ({ value: [] }),
+ splitWatchedCasesBySubmissionState: () => ({
+ watchedCases: { value: [] },
+ submittedRepresentations: { value: [] }
+ }),
+ getPortalModuleDetails: async () => ({}),
+ getFormCollectionByID: () => ({
+ LogicalCollectionName: "pinswg_cases"
+ }),
+ pLimit: () => (fn) => fn(),
+ setLocale: (value) => ({ type: "locale", value }),
+ setShowReps: () => ({ type: "showReps" }),
+ setAccountDetails: (value) => ({ type: "accountDetails", value }),
+ setLoggedInUserId: (value) => ({ type: "loggedInUserId", value }),
+ setMyCases: () => ({ type: "myCases" }),
+ setMyCasesDetails: () => ({ type: "myCasesDetails" }),
+ setMyRepresentations: () => ({ type: "myRepresentations" }),
+ setMyRepresentationsDetails: () => ({
+ type: "myRepresentationsDetails"
+ }),
+ setWatchedCases: () => ({ type: "watchedCases" }),
+ setWatchedCasesDetails: () => ({ type: "watchedCasesDetails" }),
+ setMySubmittedReps: () => ({ type: "mySubmittedReps" }),
+ setMySubmittedRepsDetails: () => ({
+ type: "mySubmittedRepsDetails"
+ }),
+ setAwaitingSubmissionFromBlob: () => ({
+ type: "awaitingSubmission"
+ }),
+ setAwaitingSubmissionDetails: () => ({
+ type: "awaitingSubmissionDetails"
+ }),
+ setContainerID: () => ({ type: "containerID" }),
+ ...overrides
+ };
+
+ vm.runInNewContext(source, context, { filename: filePath });
+
+ return {
+ getServerSideProps: context.module.exports.getServerSideProps,
+ dispatches
+ };
+};
+
+const createContext = () => ({
+ req: {
+ cookies: {},
+ headers: { cookie: "" }
+ },
+ locale: "en"
+});
+
+test("missing session redirects to signin", async () => {
+ const loader = loadIndexLoader({
+ getSession: async () => null
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("missing session identity redirects to signin", async () => {
+ const loader = loadIndexLoader({
+ getSession: async () => ({
+ user: { id: "session-user-1" }
+ })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("CRM contact lookup failure redirects to signin", async () => {
+ const loader = loadIndexLoader({
+ getPortalLogin: async () => {
+ throw new Error("CRM unavailable");
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("missing CRM contact redirects to signin", async () => {
+ const loader = loadIndexLoader({
+ getPortalLogin: async () => ({ value: [] })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("personal account lookup exception redirects to error page", async () => {
+ const loader = loadIndexLoader({
+ getCrmPersonalAccount: async () => {
+ throw new Error("CRM account lookup failed");
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("missing personal account redirects to error page", async () => {
+ const loader = loadIndexLoader({
+ getCrmPersonalAccount: async () => null
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("personal account error response redirects to error page", async () => {
+ const loader = loadIndexLoader({
+ getCrmPersonalAccount: async () => ({
+ errorCode: "CRM_CONTACT_MISSING"
+ })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("successful lookup uses contact resolved from session email", async () => {
+ let capturedEmail;
+ let capturedContactId;
+
+ const loader = loadIndexLoader({
+ getPortalLogin: async (email) => {
+ capturedEmail = email;
+ return { value: [{ contactid: "crm-contact-123" }] };
+ },
+ getCrmPersonalAccount: async (contactId) => {
+ capturedContactId = contactId;
+ return {
+ firstname: "Test",
+ pinswg_typeofinvolvement: 0,
+ pinswg_preferredlanguage: 0
+ };
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(capturedEmail, "user@example.com");
+ assert.strictEqual(capturedContactId, "crm-contact-123");
+ assert.strictEqual(result.props.containerID, "session-user-1");
+
+ const accountDispatch = loader.dispatches.find(
+ (action) => action.type === "accountDetails"
+ );
+
+ assert.strictEqual(accountDispatch.value.firstname, "Test");
+});
+
+const run = async () => {
+ let passed = 0;
+
+ for (const currentTest of tests) {
+ await currentTest.fn();
+ passed += 1;
+ }
+
+ console.log(
+ `Phase 22 myportal-index-loader-guards tests passed (${passed}/${tests.length}).`
+ );
+};
+
+module.exports = run;
+
+if (require.main === module) {
+ run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/tests/phase22/myportal-viewall-loader-guards.test.cjs b/tests/phase22/myportal-viewall-loader-guards.test.cjs
new file mode 100644
index 00000000..20b16af6
--- /dev/null
+++ b/tests/phase22/myportal-viewall-loader-guards.test.cjs
@@ -0,0 +1,314 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+
+const tests = [];
+const test = (name, fn) => tests.push({ name, fn });
+
+const loadViewAllLoader = (overrides = {}) => {
+ const filePath = path.join(
+ __dirname,
+ "..",
+ "..",
+ "pages",
+ "myportal",
+ "viewall.js"
+ );
+
+ let source = fs.readFileSync(filePath, "utf8");
+
+ // Execute the actual SSR loader, excluding the React component.
+ const start = source.indexOf("export const getServerSideProps");
+ const end = source.indexOf("const mapStateToProps", start);
+
+ assert.ok(start >= 0, "SSR loader declaration not found");
+ assert.ok(end > start, "SSR loader boundary not found");
+
+ source = source.slice(start, end);
+ source = source.replace(
+ /export\s+const\s+getServerSideProps\s*=/,
+ "const getServerSideProps ="
+ );
+ source += "\nmodule.exports = { getServerSideProps };\n";
+
+ const dispatches = [];
+ const store = {
+ dispatch: (action) => dispatches.push(action)
+ };
+
+ const action = (type) => (value) => ({ type, value });
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ console: {
+ log: () => {},
+ error: () => {}
+ },
+ wrapper: {
+ getServerSideProps: (factory) => async (ctx) => factory(store)(ctx)
+ },
+ getSession: async () => ({
+ user: {
+ id: "session-user-1",
+ email: "user@example.com"
+ }
+ }),
+ getPortalLogin: async () => ({
+ value: [{ contactid: "crm-contact-1" }]
+ }),
+ getCrmPersonalAccount: async () => ({
+ firstname: "Test",
+ pinswg_typeofinvolvement: 0
+ }),
+ consoleLogger: () => {},
+ pLimit: () => (fn) => fn(),
+ getMyCases: async () => ({ value: [] }),
+ getMyLPACases: async () => ({ value: [] }),
+ getWatchedCases: async () => ({ value: [] }),
+ getRepsFromBlob: async () => ({ value: [] }),
+ getAwaitingSubmissionFromBlob: async () => ({ value: [] }),
+ splitWatchedCasesBySubmissionState: () => ({
+ watchedCases: [],
+ submittedRepresentations: []
+ }),
+ getPortalModuleDetails: async () => ({}),
+ getFormCollectionByID: () => ({
+ LogicalCollectionName: "pinswg_cases"
+ }),
+ getBasicSearch: async () => ({ value: [] }),
+ setAccountDetails: action("accountDetails"),
+ setLoggedInUserId: action("loggedInUserId"),
+ setContainerID: action("containerID"),
+ setCurrentView: action("currentView"),
+ setMyCases: action("myCases"),
+ setMyCasesDetails: action("myCasesDetails"),
+ setWatchedCases: action("watchedCases"),
+ setWatchedCasesDetails: action("watchedCasesDetails"),
+ setMySubmittedReps: action("mySubmittedReps"),
+ setMySubmittedRepsDetails: action("mySubmittedRepsDetails"),
+ setMyRepresentations: action("myRepresentations"),
+ setMyRepresentationsDetails: action("myRepresentationsDetails"),
+ setAwaitingSubmissionFromBlob: action("awaitingSubmission"),
+ setAwaitingSubmissionDetails: action("awaitingSubmissionDetails"),
+ setSearchResults: action("searchResults"),
+ setSearch: action("search"),
+ ...overrides
+ };
+
+ vm.runInNewContext(source, context, { filename: filePath });
+
+ return {
+ getServerSideProps: context.module.exports.getServerSideProps,
+ dispatches
+ };
+};
+
+const createContext = (key = "myCases") => ({
+ query: { key },
+ req: { cookies: {} },
+ locale: "en"
+});
+
+const getDispatch = (loader, type) =>
+ loader.dispatches.find((item) => item.type === type);
+
+test("invalid view category redirects before account lookup", async () => {
+ let accountLookupCalled = false;
+
+ const loader = loadViewAllLoader({
+ getCrmPersonalAccount: async () => {
+ accountLookupCalled = true;
+ return {};
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext("invalid"));
+
+ assert.strictEqual(result.redirect.destination, "/myportal");
+ assert.strictEqual(accountLookupCalled, false);
+});
+
+test("missing session redirects to signin", async () => {
+ const loader = loadViewAllLoader({
+ getSession: async () => null
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("missing session email redirects to signin", async () => {
+ const loader = loadViewAllLoader({
+ getSession: async () => ({
+ user: { id: "session-user-1" }
+ })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("missing CRM contact redirects to signin", async () => {
+ const loader = loadViewAllLoader({
+ getPortalLogin: async () => ({ value: [] })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/auth/signin");
+});
+
+test("personal account exception redirects to error page", async () => {
+ const loader = loadViewAllLoader({
+ getCrmPersonalAccount: async () => {
+ throw new Error("CRM unavailable");
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("missing personal account redirects to error page", async () => {
+ const loader = loadViewAllLoader({
+ getCrmPersonalAccount: async () => null
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("personal account error response redirects to error page", async () => {
+ const loader = loadViewAllLoader({
+ getCrmPersonalAccount: async () => ({
+ errorCode: "CRM_CONTACT_MISSING"
+ })
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(result.redirect.destination, "/myportal/error");
+});
+
+test("successful lookup uses session-resolved CRM contact", async () => {
+ let capturedEmail;
+ let capturedContactId;
+
+ const accountDetails = {
+ firstname: "Test",
+ pinswg_typeofinvolvement: 0
+ };
+
+ const loader = loadViewAllLoader({
+ getPortalLogin: async (email) => {
+ capturedEmail = email;
+ return {
+ value: [{ contactid: "crm-contact-123" }]
+ };
+ },
+ getCrmPersonalAccount: async (contactId) => {
+ capturedContactId = contactId;
+ return accountDetails;
+ }
+ });
+
+ const result = await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(capturedEmail, "user@example.com");
+ assert.strictEqual(capturedContactId, "crm-contact-123");
+ assert.ok(result.props);
+
+ assert.strictEqual(
+ getDispatch(loader, "accountDetails").value.firstname,
+ "Test"
+ );
+ assert.strictEqual(
+ getDispatch(loader, "loggedInUserId").value,
+ "crm-contact-123"
+ );
+ assert.strictEqual(
+ getDispatch(loader, "containerID").value,
+ "session-user-1"
+ );
+ assert.strictEqual(
+ getDispatch(loader, "currentView").value.viewKey,
+ "myCases"
+ );
+});
+
+test("all supported view categories load successfully", async () => {
+ const categories = [
+ "myCases",
+ "watchedCases",
+ "mySubmittedReps",
+ "myRepresentations",
+ "awaitingSubmissionDetails"
+ ];
+
+ for (const category of categories) {
+ const loader = loadViewAllLoader();
+
+ const result = await loader.getServerSideProps(createContext(category));
+
+ assert.ok(result.props, `${category} should return props`);
+
+ assert.strictEqual(
+ getDispatch(loader, "currentView").value.viewKey,
+ category
+ );
+ }
+});
+
+test("LPA account uses associated LPA for case lookup", async () => {
+ let capturedLpaId;
+ let personalCaseLookupCalled = false;
+
+ const loader = loadViewAllLoader({
+ getCrmPersonalAccount: async () => ({
+ pinswg_typeofinvolvement: 846040012,
+ "pinswg_contact_associatedlpa@OData.Community.Display.V1.FormattedValue":
+ "lpa-123"
+ }),
+ getMyLPACases: async (lpaId) => {
+ capturedLpaId = lpaId;
+ return { value: [] };
+ },
+ getMyCases: async () => {
+ personalCaseLookupCalled = true;
+ return { value: [] };
+ }
+ });
+
+ await loader.getServerSideProps(createContext());
+
+ assert.strictEqual(capturedLpaId, "lpa-123");
+ assert.strictEqual(personalCaseLookupCalled, false);
+});
+
+const run = async () => {
+ let passed = 0;
+
+ for (const currentTest of tests) {
+ await currentTest.fn();
+ passed += 1;
+ }
+
+ console.log(
+ `Phase 22 myportal-viewall-loader-guards tests passed (${passed}/${tests.length}).`
+ );
+};
+
+module.exports = run;
+
+if (require.main === module) {
+ run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/tests/phase22/preauth-locale-resolution.test.cjs b/tests/phase22/preauth-locale-resolution.test.cjs
deleted file mode 100644
index 2e2d5b76..00000000
--- a/tests/phase22/preauth-locale-resolution.test.cjs
+++ /dev/null
@@ -1,242 +0,0 @@
-const assert = require("assert");
-const fs = require("fs");
-const path = require("path");
-const vm = require("vm");
-
-const rootDir = path.resolve(__dirname, "..", "..");
-const tests = [];
-const test = (name, fn) => tests.push({ name, fn });
-
-const loadModule = (relativePath, injected = {}) => {
- const filePath = path.join(rootDir, relativePath);
- let source = fs.readFileSync(filePath, "utf8");
- const exportNames = Array.from(
- source.matchAll(/export const\s+(\w+)\s*=/g)
- ).map((match) => match[1]);
-
- source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
- source = source.replace(/export const\s+/g, "const ");
- source = source.replace(
- /export default async function\s+(\w+)\s*\(/g,
- "async function $1("
- );
- source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`;
- source +=
- '\nif (typeof handler !== "undefined") module.exports.default = handler;\n';
-
- const context = {
- module: { exports: {} },
- exports: {},
- require,
- process,
- console: { log: () => {}, info: () => {}, warn: () => {}, error: () => {} },
- ...injected
- };
-
- vm.runInNewContext(source, context, { filename: filePath });
- return context.module.exports;
-};
-
-const normalize = (value) => JSON.parse(JSON.stringify(value));
-
-const createRes = () => {
- const state = { statusCode: null, jsonBody: undefined };
-
- return {
- state,
- status(code) {
- state.statusCode = code;
- return this;
- },
- json(body) {
- state.jsonBody = body;
- return body;
- }
- };
-};
-
-const loadLocaleResolver = ({ relayGetData }) =>
- loadModule("lib/auth/resolveCrmLocale.js", {
- azureHeadersPaged: (accessToken) => ({ accessToken }),
- escapeODataString: (value) => value.trim().replace(/'/g, "''"),
- consoleLogger: () => {},
- relayGetData,
- RELAY_POLICY_STRICT_LOGIN: {
- method: "GET",
- timeoutMs: 5000,
- maxRetries: 0,
- retryBaseDelayMs: 0,
- retryMaxDelayMs: 0
- }
- });
-
-test("pre-auth CRM locale lookup returns Welsh and selects only language", async () => {
- let capturedRequest;
- const mod = loadLocaleResolver({
- relayGetData: async (request) => {
- capturedRequest = request;
- return {
- data: {
- value: [
- {
- pinswg_preferredlanguage: 846040000,
- contactid: "must-not-be-returned",
- firstname: "Must not be returned"
- }
- ]
- }
- };
- }
- });
-
- const result = await mod.resolveCrmLocale(" Person@Example.com ");
- const selectClause = new URLSearchParams(
- capturedRequest.queryUrl.split("?")[1]
- ).get("$select");
-
- assert.strictEqual(result, "cy");
- assert.match(capturedRequest.queryUrl, /\$filter=emailaddress1 eq /);
- assert.match(capturedRequest.queryUrl, /statuscode eq 1/);
- assert.strictEqual(selectClause, "pinswg_preferredlanguage");
- assert.ok(result === "en" || result === "cy" || result === null);
- for (const field of [
- "contactid",
- "emailaddress1",
- "firstname",
- "lastname",
- "yomifullname"
- ]) {
- assert.doesNotMatch(selectClause, new RegExp(field));
- assert.doesNotMatch(result, new RegExp(field));
- }
-});
-
-test("pre-auth CRM locale lookup returns English for a non-Welsh value", async () => {
- const mod = loadLocaleResolver({
- relayGetData: async () => ({
- data: { value: [{ pinswg_preferredlanguage: 846040001 }] }
- })
- });
-
- assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en");
-});
-
-test("pre-auth CRM locale lookup returns null for no contact", async () => {
- const mod = loadLocaleResolver({
- relayGetData: async () => ({ data: { value: [] } })
- });
-
- assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null);
-});
-
-test("pre-auth CRM locale lookup preserves first-contact duplicate semantics", async () => {
- const mod = loadLocaleResolver({
- relayGetData: async () => ({
- data: {
- value: [
- { pinswg_preferredlanguage: 846040001 },
- { pinswg_preferredlanguage: 846040000 }
- ]
- }
- })
- });
-
- assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en");
-});
-
-test("pre-auth CRM locale lookup rejects malformed email without CRM access", async () => {
- let relayCalls = 0;
- const mod = loadLocaleResolver({
- relayGetData: async () => {
- relayCalls += 1;
- return { data: { value: [] } };
- }
- });
-
- assert.strictEqual(await mod.resolveCrmLocale("not-an-email"), null);
- assert.strictEqual(relayCalls, 0);
-});
-
-test("pre-auth CRM locale lookup fails closed without leaking CRM details", async () => {
- const mod = loadLocaleResolver({
- relayGetData: async () => {
- throw new Error("CRM secret response details");
- }
- });
-
- assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null);
-});
-
-test("pre-auth CRM locale lookup escapes OData apostrophes", async () => {
- let queryUrl;
- const mod = loadLocaleResolver({
- relayGetData: async (request) => {
- queryUrl = request.queryUrl;
- return { data: { value: [] } };
- }
- });
-
- await mod.resolveCrmLocale("o'connor@example.com");
-
- assert.match(queryUrl, /o''connor@example\.com/);
-});
-
-test("public sign-in locale route preserves response without CRM lookup", async () => {
- const source = fs.readFileSync(
- path.join(rootDir, "pages/api/auth/resolve-locale.js"),
- "utf8"
- );
- const mod = loadModule("pages/api/auth/resolve-locale.js");
- const res = createRes();
-
- await mod.default(
- {
- method: "POST",
- body: { email: "other@example.com", locale: "cy" },
- query: {},
- cookies: {}
- },
- res
- );
-
- assert.strictEqual(res.state.statusCode, 200);
- assert.deepStrictEqual(normalize(res.state.jsonBody), { locale: "cy" });
- assert.doesNotMatch(source, /getPortalLogin|resolveCrmLocale|relayGetData/);
-});
-
-test("both pre-auth callers no longer depend on getPortalLogin", () => {
- const nextAuthSource = fs.readFileSync(
- path.join(rootDir, "pages/api/auth/[...nextauth].js"),
- "utf8"
- );
- const routeSource = fs.readFileSync(
- path.join(rootDir, "pages/api/auth/resolve-locale.js"),
- "utf8"
- );
-
- assert.doesNotMatch(nextAuthSource, /getPortalLogin/);
- assert.doesNotMatch(routeSource, /getPortalLogin/);
- assert.match(nextAuthSource, /resolveCrmLocale/);
-});
-
-const run = async () => {
- let passed = 0;
-
- for (const currentTest of tests) {
- await currentTest.fn();
- passed += 1;
- }
-
- console.log(
- `Phase 22 pre-auth locale tests passed (${passed}/${tests.length}).`
- );
-};
-
-if (require.main === module) {
- run().catch((error) => {
- console.error(error);
- process.exit(1);
- });
-}
-
-module.exports = run;
\ No newline at end of file
diff --git a/tests/phase22/representation-list-ownership.test.cjs b/tests/phase22/representation-list-ownership.test.cjs
new file mode 100644
index 00000000..82eb7ce4
--- /dev/null
+++ b/tests/phase22/representation-list-ownership.test.cjs
@@ -0,0 +1,157 @@
+const assert = require("assert");
+const {
+ loadModule,
+ createRes,
+ createNextConnectMock,
+ respondSuccessMock,
+ respondErrorMock
+} = require("../phase21/_shared.cjs");
+
+const tests = [];
+const test = (name, fn) => tests.push({ name, fn });
+
+const loadRepresentationList = ({ session = null, administrator = false } = {}) => {
+ const calls = { list: [], download: [] };
+ const mod = loadModule("pages/api/file/getrepsblob.js", {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ getServerSession: async () => session,
+ authOptions: () => ({}),
+ readAdminSessionCookie: () =>
+ administrator ? "valid-admin-session" : null,
+ validateAdminSession: () => administrator,
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ consoleLogger: () => {},
+ getRepsBlobs: async (...args) => {
+ calls.list.push(args);
+ return [{ path: "CASE/REP_rep.json" }];
+ },
+ downloadAllRepsFiles: async (...args) => {
+ calls.download.push(args);
+ return { value: [{ id: "rep-1" }] };
+ }
+ });
+
+ return { mod, calls };
+};
+
+const representationListRequest = (query = {}) => ({
+ query: {
+ container: "organisation-container",
+ hash: "expected",
+ ...query
+ },
+ headers: {}
+});
+
+test("representation listing rejects a missing session before Azure access", async () => {
+ const loaded = loadRepresentationList();
+ const res = createRes();
+
+ await loaded.mod.default.handler(representationListRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assert.strictEqual(loaded.calls.list.length, 0);
+ assert.strictEqual(loaded.calls.download.length, 0);
+});
+
+test("representation listing rejects cross-user container access before Azure access", async () => {
+ const loaded = loadRepresentationList({
+ session: { user: { id: "different-container" } }
+ });
+ const res = createRes();
+
+ await loaded.mod.default.handler(representationListRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(loaded.calls.list.length, 0);
+ assert.strictEqual(loaded.calls.download.length, 0);
+});
+
+test("representation listing preserves authorised organisation-container access", async () => {
+ const loaded = loadRepresentationList({
+ session: { user: { id: "organisation-container" } }
+ });
+ const res = createRes();
+
+ await loaded.mod.default.handler(representationListRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.deepStrictEqual(loaded.calls.list, [["organisation-container"]]);
+ assert.strictEqual(loaded.calls.download.length, 1);
+});
+
+test("representation listing preserves existing administrative access", async () => {
+ const loaded = loadRepresentationList({ administrator: true });
+ const res = createRes();
+
+ await loaded.mod.default.handler(representationListRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.calls.list.length, 1);
+});
+
+test("representation listing rejects a missing identifier before authentication or Azure access", async () => {
+ let sessionCalls = 0;
+ const loaded = loadRepresentationList({
+ session: { user: { id: "organisation-container" } }
+ });
+ loaded.mod = loadModule("pages/api/file/getrepsblob.js", {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ getServerSession: async () => {
+ sessionCalls += 1;
+ return { user: { id: "organisation-container" } };
+ },
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ consoleLogger: () => {},
+ getRepsBlobs: async () => {
+ loaded.calls.list.push([]);
+ return [];
+ },
+ downloadAllRepsFiles: async () => {
+ loaded.calls.download.push([]);
+ return {};
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ representationListRequest({ container: "" }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
+ assert.strictEqual(sessionCalls, 0);
+ assert.strictEqual(loaded.calls.list.length, 0);
+ assert.strictEqual(loaded.calls.download.length, 0);
+});
+
+const run = async () => {
+ let passed = 0;
+ for (const currentTest of tests) {
+ await currentTest.fn();
+ passed += 1;
+ }
+ console.log(
+ `Representation listing ownership tests passed (${passed}/${tests.length}).`
+ );
+};
+
+module.exports = run;
+
+if (require.main === module) {
+ run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs
index 05764a66..e53a3071 100644
--- a/tests/phase7/service-behaviour.test.cjs
+++ b/tests/phase7/service-behaviour.test.cjs
@@ -65,6 +65,93 @@ test("document/getAwaitingSubmissionFromBlob logs and returns undefined on failu
assert.strictEqual(logger.calls.length, 1);
});
+test("document/getRepsFromBlob forwards cookies only to trusted origin without redirects", async () => {
+ const axios = createAxiosMock();
+ const logger = createLoggerMock();
+
+ axios.getHandler = async () => ({
+ data: { value: ["representation"] }
+ });
+
+ const document = loadServiceModule("documentDirectService.js", {
+ axios,
+ BASE_URL: "http://example.local",
+ consoleLogger: logger.consoleLogger,
+ hashAPIPath: () => "&hash=abc123"
+ });
+
+ const result = await document.getRepsFromBlob("container-1", {
+ headers: {
+ Cookie: "next-auth.session-token=test-session"
+ }
+ });
+
+ assert.deepStrictEqual(normalize(result), {
+ value: ["representation"]
+ });
+
+ assert.strictEqual(axios.calls.length, 1);
+ assert.strictEqual(
+ axios.calls[0].url,
+ "http://example.local/api/file/getrepsblob?container=container-1&hash=abc123"
+ );
+ assert.strictEqual(
+ axios.calls[0].config.headers.Cookie,
+ "next-auth.session-token=test-session"
+ );
+ assert.strictEqual(axios.calls[0].config.maxRedirects, 0);
+ assert.strictEqual(logger.calls.length, 0);
+});
+
+test("document/getRepsFromBlob rejects untrusted origin before HTTP request", async () => {
+ const axios = createAxiosMock();
+ const logger = createLoggerMock();
+
+ const document = loadServiceModule("documentDirectService.js", {
+ axios,
+ BASE_URL: "https://untrusted.example",
+ consoleLogger: logger.consoleLogger,
+ hashAPIPath: () => "&hash=abc123"
+ });
+
+ await assert.rejects(
+ document.getRepsFromBlob("container-1", {
+ headers: {
+ Cookie: "next-auth.session-token=test-session"
+ }
+ }),
+ /Untrusted representation request destination/
+ );
+
+ assert.strictEqual(axios.calls.length, 0);
+ assert.strictEqual(logger.calls.length, 0);
+});
+
+test("document/getRepsFromBlob preserves requests without forwarded cookies", async () => {
+ const axios = createAxiosMock();
+ const logger = createLoggerMock();
+
+ axios.getHandler = async () => ({
+ data: { value: ["representation"] }
+ });
+
+ const document = loadServiceModule("documentDirectService.js", {
+ axios,
+ BASE_URL: "http://example.local",
+ consoleLogger: logger.consoleLogger,
+ hashAPIPath: () => "&hash=abc123"
+ });
+
+ const result = await document.getRepsFromBlob("container-1");
+
+ assert.deepStrictEqual(normalize(result), {
+ value: ["representation"]
+ });
+ assert.strictEqual(axios.calls.length, 1);
+ assert.strictEqual(axios.calls[0].config, undefined);
+ assert.strictEqual(logger.calls.length, 0);
+});
+
test("portal/deleteWatchedCases uses signer hash and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
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: () => {}
diff --git a/tests/serviceHarness.cjs b/tests/serviceHarness.cjs
index aea3882b..a489b5ce 100644
--- a/tests/serviceHarness.cjs
+++ b/tests/serviceHarness.cjs
@@ -89,8 +89,8 @@ const loadServiceModule = (fileName, injected = {}) => {
return injected.axios(config).then((response) => response.data);
};
- const defaultGetFileJson = (url) => {
- return defaultGetJson(url);
+ const defaultGetFileJson = (url, config) => {
+ return defaultGetJson(url, config);
};
const defaultBuildHashedQueryUrl = async (queryUrl) => {
@@ -207,8 +207,14 @@ const loadServiceModule = (fileName, injected = {}) => {
module: { exports: {} },
exports: {},
require,
+ URL,
URLSearchParams,
encodeURIComponent,
+ process: injected.process || {
+ env: {
+ NEXTAUTH_URL: "http://example.local"
+ }
+ },
FormData: global.FormData,
console: {
log: () => {},