Merge ITHC recovery into mitigation baseline

This commit is contained in:
2026-11-07 07:55:52 +00:00
48 changed files with 2767 additions and 1738 deletions
+2 -2
View File
@@ -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) => {
-25
View File
@@ -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 : "") +
-4
View File
@@ -1,7 +1,5 @@
import {
getPersonalAccount,
getLogin,
updatePassword,
updateAccount,
createAccount,
getEmailAccountCheck,
@@ -12,8 +10,6 @@ import {
export {
getPersonalAccount,
getLogin,
updatePassword,
updateAccount,
createAccount,
getEmailAccountCheck,
-29
View File
@@ -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,
-2
View File
@@ -4,7 +4,6 @@ import {
getSIPSEvents,
getSIPSMedia,
getAppealID,
createNewCase,
createNewCaseBlob,
updateCase,
updateCaseBlob,
@@ -26,7 +25,6 @@ export {
getSIPSEvents,
getSIPSMedia,
getAppealID,
createNewCase,
createNewCaseBlob,
updateCase,
updateCaseBlob,
+37 -2
View File
@@ -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);
});
-184
View File
@@ -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 (
<>
<div
className={
touched && error
? "govuk-form-group govuk-form-group--error"
: "govuk-form-group "
}
>
<label className="govuk-label " htmlFor={id} id={id + "_label"}>
{label}
</label>
{touched && error && (
<span id={id + "-error"} className="govuk-error-message">
<span className="govuk-visually-hidden">Error:</span>{" "}
{touched &&
((error && (
<span>{errorMsg ? errorMsg : error}</span>
)) ||
(warning && <span>{warning}</span>))}
</span>
)}
<input
{...input}
className={className}
name={name}
id={id}
type={type}
/>
</div>
</>
);
};
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 ? (
<form onSubmit={handleSubmit(onHandleSubmit)}>
<fieldset className="govuk-fieldset">
<Field
name="newpassword"
id="newpassword"
component={RenderTextfield}
type="password"
className="govuk-input govuk-input--width-20"
validate={[required, minLength(6)]}
label="New password"
/>
<Field
name="confirmpassword"
id="confirmpassword"
component={RenderTextfield}
type="password"
className="govuk-input govuk-input--width-20"
validate={[required, passwordsMustMatch]}
label="Confirm new password"
/>
</fieldset>
<div className="govuk-button-group">
<button
type="submit"
className="govuk-button"
data-module="govuk-button"
>
Continue
</button>
<Link
href={
"/myportal?q=" +
props.accountDetails.loggedinUserId
}
className="govuk-link"
>
Cancel
</Link>
</div>
</form>
) : (
<>
<p className="govuk-body">
<Link
href={
"/myportal?q=" +
props.accountDetails.loggedinUserId
}
className="govuk-link"
>
Go to My Portal
</Link>
</p>
</>
)}
</>
);
};
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)
);
-5
View File
@@ -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`
-18
View File
@@ -27,24 +27,6 @@ export default function YourAccount() {
</Link>
</div>
</div>
{/* <div className="card-body">
<h3 className="heading-small card-heading">
{t("myportal:changepassword-card-title")}
</h3>
<p className="govuk-body-s">
{" "}
{t("myportal:changepassword-card-paragraph-one")}
</p>
<div>
<Link href="/account/changepassword">
<a className="govuk-button-secondary">
{t("myportal:changepassword-card-link")}
</a>
</Link>
</div>
</div> */}
</div>
</>
);
-7
View File
@@ -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;
-42
View File
@@ -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;
}
};
+3 -1
View File
@@ -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(
@@ -472,15 +472,6 @@ export const createDetailAndAccountRouteRenderers = ({
{t("account:account-title")}
</li>
</>
),
"/account/changepassword": () => (
<>
{renderLinkCrumb(cyMyPortalHref, t("common:breadcrumb-my-portal"))}
<li className="govuk-breadcrumbs__list-item">
Update your password
</li>
</>
)
});
+16
View File
@@ -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;
};
+29
View File
@@ -0,0 +1,29 @@
import axios from "axios";
import { getToken } from "../../actions/core/token";
import { hashAPIPath } from "../../actions/core/hash";
const WEBAPI_URL =
process.env.RELAY_ROOT ||
"https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/";
export const updateCrmContact = async (contactId, updatePayload) => {
const token = await getToken();
const queryUrl = "contacts(" + contactId + ")";
const config = {
method: "patch",
url: WEBAPI_URL + queryUrl + hashAPIPath(queryUrl),
headers: {
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
Accept: "application/json",
Prefer: 'odata.include-annotations="*",return=representation',
Authorization: "Bearer " + token.access_token,
"Content-Type": "application/json"
},
data: JSON.stringify(updatePayload)
};
const response = await axios(config);
return response.data;
};
-120
View File
@@ -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 <NoSessionWarning loading={true} />;
}
if (status === "unauthenticated") {
router.push("/auth/signin");
return <NoSessionWarning />;
}
return (
<div>
<Head>
<title>
{t("search:page-title")} - {t("common:service-name")}
</title>
</Head>
<div id="page_wrapper">
<CookieBanner />
<Header />
<div className="govuk-grid-row">
<div className="govuk-grid-column-full">
<div className="govuk-width-container">
<Breadcrumbs slug={appealtypes} props={props} />
<main
className="govuk-main-wrapper--auto-spacing"
id="main-content"
role="main"
>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds">
{passwordUpdated == true ? (
<h1>
Your password has been changed
</h1>
) : (
<h1>Change password</h1>
)}
<ChangePassword
props={props}
setPasswordUpdated={
setPasswordUpdated
}
passwordUpdated={passwordUpdated}
/>
</div>
</div>
</main>
</div>
</div>
</div>
<Footer footerLinks={footerLinks} ticketnumber={props} />
</div>
</div>
);
};
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);
+28 -7
View File
@@ -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"
});
}
}
}
+26 -1
View File
@@ -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) {
+30 -3
View File
@@ -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 });
}
}
-150
View File
@@ -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"
});
}
}
-43
View File
@@ -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";
+166 -31
View File
@@ -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);
+2 -1
View File
@@ -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, {
-69
View File
@@ -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"
}
});
}
+4 -45
View File
@@ -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;
+110 -42
View File
@@ -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, {
-71
View File
@@ -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"
});
}
}
@@ -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);
}
+5 -3
View File
@@ -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") ||
[
+24
View File
@@ -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)
+25 -2
View File
@@ -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) {
+2 -1
View File
@@ -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(
+23 -7
View File
@@ -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)
]);
+24 -6
View File
@@ -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
+1 -1
View File
@@ -155,7 +155,7 @@ test("createappealcompletemessage_api rejects missing hash with 400", async () =
createBlob: async () => ({}),
getCaseBlob: async () => ({}),
createCaseCompleteMessage: async () => ({}),
updateAccount: async () => ({}),
updateCrmContact: async () => ({}),
consoleLogger: () => {},
_: { isEmpty: (value) => !value },
nextConnect,
+3
View File
@@ -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: () => {},
File diff suppressed because it is too large Load Diff
+346 -121
View File
@@ -149,7 +149,9 @@ const assertNoUploadWrites = (calls) => {
};
test("upload rejects a missing NextAuth session before HMAC or Azure write", async () => {
const loaded = loadUploadAuthorization({ getServerSession: async () => null });
const loaded = loadUploadAuthorization({
getServerSession: async () => null
});
const res = createRes();
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
@@ -906,9 +908,15 @@ for (const inputCase of [
{ name: "missing container", body: { container: undefined } },
{ name: "malformed container", body: { container: "Invalid_Container" } },
{ name: "missing case identifier", body: { casefolderID: undefined } },
{ name: "case identifier path separator", body: { casefolderID: "TMP/CASE-1" } },
{
name: "case identifier path separator",
body: { casefolderID: "TMP/CASE-1" }
},
{ name: "case identifier traversal segment", body: { casefolderID: ".." } },
{ name: "case identifier control character", body: { casefolderID: "TMP\u0000CASE" } }
{
name: "case identifier control character",
body: { casefolderID: "TMP\u0000CASE" }
}
]) {
test(`admin case deletion rejects ${inputCase.name} without deletion`, async () => {
const loaded = loadAdminCaseDeletion();
@@ -917,7 +925,8 @@ for (const inputCase of [
request.body = { ...request.body, ...inputCase.body };
Object.keys(request.body).forEach((key) => {
if (typeof request.body[key] === "undefined") delete request.body[key];
if (typeof request.body[key] === "undefined")
delete request.body[key];
});
await loaded.mod.default(request, res);
@@ -938,10 +947,9 @@ test("valid administrator can delete a cross-container case prefix with the exis
assert.deepStrictEqual(loaded.calls.deletion, [
[request.body.container, request.body.casefolderID]
]);
assert.deepStrictEqual(
JSON.parse(JSON.stringify(res.state.jsonBody)),
{ data: loaded.payload }
);
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
data: loaded.payload
});
});
test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", async () => {
@@ -1166,7 +1174,10 @@ test("valid administrative representation JSON edit preserves the existing mutat
const updatedJson = JSON.parse(loaded.uploaded.content);
assert.strictEqual(Object.hasOwn(updatedJson, "repComplete"), false);
assert.strictEqual(updatedJson.containerID, loaded.sourceJson.containerID);
assert.strictEqual(updatedJson.ticketnumber, loaded.sourceJson.ticketnumber);
assert.strictEqual(
updatedJson.ticketnumber,
loaded.sourceJson.ticketnumber
);
assert.strictEqual(
updatedJson.representationType,
loaded.sourceJson.representationType
@@ -1234,9 +1245,7 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant",
getServerSession: async () => ({ user: { id: "container-1" } }),
authOptions: () => ({}),
hashAPIPath: (queryPath) =>
queryPath.includes(
"/api/file/deleteblob?container=container-1"
)
queryPath.includes("/api/file/deleteblob?container=container-1")
? "&hash=expected"
: "&hash=other",
respondError: respondErrorMock,
@@ -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) {
@@ -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\)/
+2 -5
View File
@@ -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: {
@@ -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 () => {
@@ -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);
});
}
@@ -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);
});
}
@@ -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;
@@ -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);
});
}
+87
View File
@@ -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();
+1 -1
View File
@@ -140,7 +140,7 @@ test("createappealcompletemessage_api rejects invalid hash with 400", async () =
createBlob: async () => {},
getCaseBlob: async () => {},
createCaseCompleteMessage: () => {},
updateAccount: async () => {},
updateCrmContact: async () => {},
_: { isEmpty: (value) => !value },
nextConnect,
middleware: () => {}
+8 -2
View File
@@ -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: () => {},