From 4fb62a67736be526ab1cea3faf4197d9383248a3 Mon Sep 17 00:00:00 2001 From: Robert Bond Date: Thu, 14 May 2026 10:27:12 +0000 Subject: [PATCH] Merged PR 2317: Auth stabilisation: add reason-coded guards to MyPortal loaders Adds incremental auth/session hardening across myportal loader paths (loadMyPortalAppealPage, searchresults, addresssearchresults) with explicit guard ordering and reason-coded diagnostics for missing session, missing session identity, missing cookie identity, contact/account lookup failures, and upstream dependency failures. Includes targeted Phase22 loader guard tests and keeps redirect behaviour policy unchanged. Related work items: #23020 --- lib/myportal/loadMyPortalAppealPage.js | 44 +++++- pages/myportal/addresssearchresults.js | 102 ++++++++++-- pages/myportal/searchresults.js | 80 +++++++++- tests/phase22/index.test.cjs | 4 + ...ddresssearchresults-loader-guards.test.cjs | 149 ++++++++++++++++++ tests/phase22/myportal-loader-guards.test.cjs | 31 ++++ ...ortal-searchresults-loader-guards.test.cjs | 146 +++++++++++++++++ 7 files changed, 532 insertions(+), 24 deletions(-) create mode 100644 tests/phase22/myportal-addresssearchresults-loader-guards.test.cjs create mode 100644 tests/phase22/myportal-searchresults-loader-guards.test.cjs diff --git a/lib/myportal/loadMyPortalAppealPage.js b/lib/myportal/loadMyPortalAppealPage.js index b3371b08..d3abee15 100644 --- a/lib/myportal/loadMyPortalAppealPage.js +++ b/lib/myportal/loadMyPortalAppealPage.js @@ -44,12 +44,27 @@ export async function loadMyPortalAppealPage(ctx) { } const session = await getSession(ctx); - if (!session || !session?.user?.id || !session?.user?.email) { + if (!session) { consoleLogger({ - name: "MyPortalAppealLoaderMissingSession", + name: "MyPortalAppealLoaderAuthGuard", + reasonCode: "NO_SESSION", message: - "loadMyPortalAppealPage missing session identity; redirecting to signin", - hasSession: !!session, + "loadMyPortalAppealPage missing session; redirecting to signin" + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + if (!session?.user?.id || !session?.user?.email) { + consoleLogger({ + name: "MyPortalAppealLoaderAuthGuard", + reasonCode: "NO_SESSION_USER", + message: + "loadMyPortalAppealPage missing session user identity; redirecting to signin", hasUserId: !!session?.user?.id, hasUserEmail: !!session?.user?.email }); @@ -68,7 +83,8 @@ export async function loadMyPortalAppealPage(ctx) { if (!loggedInUser) { consoleLogger({ - name: "MyPortalAppealLoaderMissingPinsUserCookie", + name: "MyPortalAppealLoaderAuthGuard", + reasonCode: "NO_PINSUSER_COOKIE", message: "loadMyPortalAppealPage missing pinsUser cookie; redirecting to signin" }); @@ -107,6 +123,7 @@ export async function loadMyPortalAppealPage(ctx) { } catch (_error) { consoleLogger({ name: "MyPortalAppealLoaderDependencyFailure", + reasonCode: "UPSTREAM_FAILURE", message: "loadMyPortalAppealPage dependency fetch failed; redirecting to /myportal", appealType: query?.appealtypes, @@ -121,6 +138,23 @@ export async function loadMyPortalAppealPage(ctx) { }; } + if (!accountDetails || accountDetails?.errorCode) { + consoleLogger({ + name: "MyPortalAppealLoaderContactGuard", + reasonCode: "CONTACT_LOOKUP_FAILED", + message: + "loadMyPortalAppealPage account contact lookup failed; redirecting to signin", + hasAccountDetails: !!accountDetails, + hasErrorCode: !!accountDetails?.errorCode + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + // Optional: awaiting-submission view setup when query.key exists let awaitingSubmissionFromBlob = null; if (Object.prototype.hasOwnProperty.call(query, "key")) { diff --git a/pages/myportal/addresssearchresults.js b/pages/myportal/addresssearchresults.js index bbe583c1..96b643f8 100644 --- a/pages/myportal/addresssearchresults.js +++ b/pages/myportal/addresssearchresults.js @@ -3,7 +3,7 @@ import useTranslation from "next-translate/useTranslation"; import Head from "next/head"; import { useRouter } from "next/router"; import { connect } from "react-redux"; -import { getIP } from "../../actions/core/logger"; +import { consoleLogger, getIP } from "../../actions/core/logger"; import { getPersonalAccount } from "../../actions/services/accountService"; import { getPortalModuleDetails } from "../../actions/services/caseService"; import { @@ -142,8 +142,6 @@ export const getServerSideProps = wrapper.getServerSideProps( (store) => async (ctx) => { const { query, req } = ctx; getIP(req); - const searchResultsObj = await getAddressSearch(query); - const searchDetailsObj = searchResultsObj; const showLoginCheck = process.env.SHOWLOGIN || false; const showReps = process.env.SHOWREPRESENTATIONS || false; store.dispatch(setShowReps(showReps, showLoginCheck)); @@ -155,7 +153,12 @@ export const getServerSideProps = wrapper.getServerSideProps( let thisSession = await getSession(ctx); if (!thisSession) { - console.log("not has sesssion......."); + consoleLogger({ + name: "MyPortalAddressSearchResultsAuthGuard", + reasonCode: "NO_SESSION", + message: + "myportal addresssearchresults loader missing session; redirecting to signin" + }); return { redirect: { destination: "/auth/signin", @@ -163,20 +166,91 @@ export const getServerSideProps = wrapper.getServerSideProps( } }; } else { + if (!thisSession?.user?.id || !thisSession?.user?.email) { + consoleLogger({ + name: "MyPortalAddressSearchResultsAuthGuard", + reasonCode: "NO_SESSION_USER", + message: + "myportal addresssearchresults loader missing session user identity; redirecting to signin", + hasSessionUserId: !!thisSession?.user?.id, + hasSessionUserEmail: !!thisSession?.user?.email + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + if (!loggedInUser) { + consoleLogger({ + name: "MyPortalAddressSearchResultsAuthGuard", + reasonCode: "NO_PINSUSER_COOKIE", + message: + "myportal addresssearchresults loader missing pinsUser cookie; redirecting to signin" + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + thisSession != false && store.dispatch(setContainerID(thisSession.user.id)); - const [accountDetails, searchResultsObj, watchedCases] = - await Promise.all([ - getPersonalAccount(loggedInUser), - getAdvancedSearch(query), - getWatchedCases(loggedInUser) - ]); + let accountDetails; + let searchResultsObj; + let watchedCases; + let searchDetailsObj; + let watchedCasesDetails; - const [searchDetailsObj, watchedCasesDetails] = await Promise.all([ - getSearchDetails(searchResultsObj), - getDetails(watchedCases, "myWatchedCases") - ]); + try { + [accountDetails, searchResultsObj, watchedCases] = + await Promise.all([ + getPersonalAccount(loggedInUser), + getAdvancedSearch(query), + getWatchedCases(loggedInUser) + ]); + + [searchDetailsObj, watchedCasesDetails] = await Promise.all([ + getSearchDetails(searchResultsObj), + getDetails(watchedCases, "myWatchedCases") + ]); + } catch (error) { + consoleLogger({ + name: "MyPortalAddressSearchResultsAuthGuard", + reasonCode: "UPSTREAM_FAILURE", + message: + "myportal addresssearchresults loader dependency call failed; redirecting to signin", + error: error?.message + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + if (!accountDetails || accountDetails?.errorCode) { + consoleLogger({ + name: "MyPortalAddressSearchResultsAuthGuard", + reasonCode: "CONTACT_LOOKUP_FAILED", + message: + "myportal addresssearchresults loader account lookup failed; redirecting to signin", + hasAccountDetails: !!accountDetails, + hasErrorCode: !!accountDetails?.errorCode + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } store.dispatch(setAccountDetails(accountDetails)); store.dispatch(setSearchResults(searchResultsObj)); diff --git a/pages/myportal/searchresults.js b/pages/myportal/searchresults.js index 98b3633a..dcc2e244 100644 --- a/pages/myportal/searchresults.js +++ b/pages/myportal/searchresults.js @@ -2,7 +2,7 @@ import useTranslation from "next-translate/useTranslation"; import Head from "next/head"; import { useRouter } from "next/router"; import { connect } from "react-redux"; -import { getIP } from "../../actions/core/logger"; +import { consoleLogger, getIP } from "../../actions/core/logger"; import { getPersonalAccount, getPortalLogin @@ -90,7 +90,12 @@ export const getServerSideProps = wrapper.getServerSideProps( store.dispatch(setShowReps(showReps, showLoginCheck)); if (!thisSession) { - console.log("not has sesssion......."); + consoleLogger({ + name: "MyPortalSearchResultsAuthGuard", + reasonCode: "NO_SESSION", + message: + "myportal searchresults loader missing session; redirecting to signin" + }); return { redirect: { destination: "/auth/signin", @@ -98,12 +103,77 @@ export const getServerSideProps = wrapper.getServerSideProps( } }; } else { - let loggedInUser = await getPortalLogin(thisSession.user.email); + if (!thisSession?.user?.id || !thisSession?.user?.email) { + consoleLogger({ + name: "MyPortalSearchResultsAuthGuard", + reasonCode: "NO_SESSION_USER", + message: + "myportal searchresults loader missing session user identity; redirecting to signin", + hasSessionUserId: !!thisSession?.user?.id, + hasSessionUserEmail: !!thisSession?.user?.email + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + if (!cookies?.pinsUser) { + consoleLogger({ + name: "MyPortalSearchResultsAuthGuard", + reasonCode: "NO_PINSUSER_COOKIE", + message: + "myportal searchresults loader missing pinsUser cookie; redirecting to signin" + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + let loggedInUser; + try { + const portalLogin = await getPortalLogin( + thisSession.user.email + ); + loggedInUser = portalLogin?.value?.[0]?.contactid; + } catch (error) { + consoleLogger({ + name: "MyPortalSearchResultsAuthGuard", + reasonCode: "UPSTREAM_FAILURE", + message: + "myportal searchresults loader portal login lookup failed; redirecting to signin", + error: error?.message + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + if (!loggedInUser) { + consoleLogger({ + name: "MyPortalSearchResultsAuthGuard", + reasonCode: "CONTACT_LOOKUP_FAILED", + message: + "myportal searchresults loader missing CRM contact id; redirecting to signin" + }); + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } //console.log("sssss", searchResultsObj); - loggedInUser = loggedInUser.value[0].contactid; - const watchedCases = await getWatchedCases(loggedInUser); //console.log("sssss", loggedInUser); diff --git a/tests/phase22/index.test.cjs b/tests/phase22/index.test.cjs index 8ac151d1..6253e3ec 100644 --- a/tests/phase22/index.test.cjs +++ b/tests/phase22/index.test.cjs @@ -13,6 +13,8 @@ const runRepresentationBuildRepsArrRulesTests = require("./representation-build- const runSessionClientTests = require("./session-client-behaviour.test.cjs"); const runNewAppealLoaderGuardTests = require("./newappeal-loader-guards.test.cjs"); const runMyPortalLoaderGuardTests = require("./myportal-loader-guards.test.cjs"); +const runMyPortalSearchResultsLoaderGuardTests = require("./myportal-searchresults-loader-guards.test.cjs"); +const runMyPortalAddressSearchResultsLoaderGuardTests = require("./myportal-addresssearchresults-loader-guards.test.cjs"); const runRepresentationLoaderGuardTests = require("./representation-loader-guards.test.cjs"); const run = async () => { @@ -31,6 +33,8 @@ const run = async () => { await runSessionClientTests(); await runNewAppealLoaderGuardTests(); await runMyPortalLoaderGuardTests(); + await runMyPortalSearchResultsLoaderGuardTests(); + await runMyPortalAddressSearchResultsLoaderGuardTests(); await runRepresentationLoaderGuardTests(); console.log("Phase 22 combined suite passed."); }; diff --git a/tests/phase22/myportal-addresssearchresults-loader-guards.test.cjs b/tests/phase22/myportal-addresssearchresults-loader-guards.test.cjs new file mode 100644 index 00000000..4db5f6ee --- /dev/null +++ b/tests/phase22/myportal-addresssearchresults-loader-guards.test.cjs @@ -0,0 +1,149 @@ +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 loadMyPortalAddressSearchResultsPage = (overrides = {}) => { + const filePath = path.join( + __dirname, + "..", + "..", + "pages", + "myportal", + "addresssearchresults.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.slice(source.indexOf("export const getServerSideProps")); + source = source.replace( + /export\s+const\s+getServerSideProps\s*=\s*/, + "const getServerSideProps = " + ); + source = source.replace(/const mapStateToProps[\s\S]*$/, ""); + source += "\nmodule.exports = { getServerSideProps };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + getIP: () => {}, + consoleLogger: () => {}, + getSession: async () => ({ user: { id: "u-1", email: "x@y.z" } }), + getPersonalAccount: async () => ({}), + getAddressSearch: async () => ({ value: [] }), + getAdvancedSearch: async () => ({ value: [] }), + getWatchedCases: async () => ({ value: [] }), + getSearchDetails: async () => ({ value: [] }), + getPortalModuleDetails: async () => ({}), + getFormCollectionByID: () => ({ LogicalCollectionName: "x" }), + setShowReps: () => ({}), + setSearch: () => ({}), + setContainerID: () => ({}), + setAccountDetails: () => ({}), + setSearchResults: () => ({}), + setSearchDetails: () => ({}), + setWatchedCases: () => ({}), + setWatchedCasesDetails: () => ({}), + setLoggedInUserId: () => ({}), + wrapper: { + getServerSideProps: (factory) => async (ctx) => { + const store = { dispatch: () => {} }; + return factory(store)(ctx); + } + }, + process: { env: {} }, + console, + ...overrides + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +test("addresssearchresults redirects to signin when session is missing", async () => { + const mod = loadMyPortalAddressSearchResultsPage({ + getSession: async () => null + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("addresssearchresults redirects to signin when session user identity is missing", async () => { + const mod = loadMyPortalAddressSearchResultsPage({ + getSession: async () => ({ user: { id: "u-1" } }) + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("addresssearchresults redirects to signin when pinsUser cookie is missing", async () => { + const mod = loadMyPortalAddressSearchResultsPage(); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: {} } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("addresssearchresults classifies contact lookup failure and redirects to signin", async () => { + let capturedLog = null; + const mod = loadMyPortalAddressSearchResultsPage({ + getPersonalAccount: async () => ({ errorCode: "CONTACT_MISSING" }), + consoleLogger: (entry) => { + capturedLog = entry; + } + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); + assert.strictEqual(capturedLog.reasonCode, "CONTACT_LOOKUP_FAILED"); +}); + +test("addresssearchresults classifies upstream failure and redirects to signin", async () => { + let capturedLog = null; + const mod = loadMyPortalAddressSearchResultsPage({ + getAdvancedSearch: async () => { + throw new Error("search unavailable"); + }, + consoleLogger: (entry) => { + capturedLog = entry; + } + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); + assert.strictEqual(capturedLog.reasonCode, "UPSTREAM_FAILURE"); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + console.log( + `Phase 22 myportal-addresssearchresults-loader-guards tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/myportal-loader-guards.test.cjs b/tests/phase22/myportal-loader-guards.test.cjs index d5c9ae5a..8946f717 100644 --- a/tests/phase22/myportal-loader-guards.test.cjs +++ b/tests/phase22/myportal-loader-guards.test.cjs @@ -57,6 +57,19 @@ test("myportal loader redirects to signin when session is missing", async () => assert.strictEqual(result.redirect.destination, "/auth/signin"); }); +test("myportal loader redirects to signin when session user identity is missing", async () => { + const mod = loadMyPortalLoader({ + getSession: async () => ({ user: { id: "u-1" } }) + }); + + const result = await mod.loadMyPortalAppealPage({ + query: { appealtypes: "s78", apt: "1", casereference: "CAS-1" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + test("myportal loader redirects to signin when pinsUser cookie is missing", async () => { const mod = loadMyPortalLoader(); @@ -99,6 +112,24 @@ test("myportal loader redirects to myportal when dependency fetch fails", async assert.strictEqual(result.redirect.destination, "/myportal"); }); +test("myportal loader classifies account lookup failure and redirects to signin", async () => { + let capturedLog = null; + const mod = loadMyPortalLoader({ + getPersonalAccount: async () => ({ errorCode: "CRM_CONTACT_MISSING" }), + consoleLogger: (entry) => { + capturedLog = entry; + } + }); + + const result = await mod.loadMyPortalAppealPage({ + query: { appealtypes: "s78", apt: "1", casereference: "CAS-1" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + + assert.strictEqual(result.redirect.destination, "/auth/signin"); + assert.strictEqual(capturedLog.reasonCode, "CONTACT_LOOKUP_FAILED"); +}); + const run = async () => { let passed = 0; diff --git a/tests/phase22/myportal-searchresults-loader-guards.test.cjs b/tests/phase22/myportal-searchresults-loader-guards.test.cjs new file mode 100644 index 00000000..79a8c598 --- /dev/null +++ b/tests/phase22/myportal-searchresults-loader-guards.test.cjs @@ -0,0 +1,146 @@ +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 loadMyPortalSearchResultsPage = (overrides = {}) => { + const filePath = path.join( + __dirname, + "..", + "..", + "pages", + "myportal", + "searchresults.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.slice(source.indexOf("export const getServerSideProps")); + source = source.replace( + /export\s+const\s+getServerSideProps\s*=\s*/, + "const getServerSideProps = " + ); + source = source.replace(/const mapStateToProps[\s\S]*$/, ""); + source += "\nmodule.exports = { getServerSideProps };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + getIP: () => {}, + consoleLogger: () => {}, + getSession: async () => ({ + user: { id: "u-1", email: "test@example.com" } + }), + getPortalLogin: async () => ({ value: [{ contactid: "contact-1" }] }), + getPersonalAccount: async () => ({}), + getWatchedCases: async () => ({ value: [] }), + getPortalModuleDetails: async () => ({}), + getFormCollectionByID: () => ({ LogicalCollectionName: "x" }), + setShowReps: () => ({}), + setAccountDetails: () => ({}), + setSearch: () => ({}), + setWatchedCases: () => ({}), + setWatchedCasesDetails: () => ({}), + setLoggedInUserId: () => ({}), + setContainerID: () => ({}), + wrapper: { + getServerSideProps: (factory) => async (ctx) => { + const store = { dispatch: () => {} }; + return factory(store)(ctx); + } + }, + process: { env: {} }, + console, + ...overrides + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +test("myportal searchresults redirects to signin when session is missing", async () => { + const mod = loadMyPortalSearchResultsPage({ getSession: async () => null }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("myportal searchresults redirects to signin when session user identity is missing", async () => { + const mod = loadMyPortalSearchResultsPage({ + getSession: async () => ({ user: { id: "u-1" } }) + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("myportal searchresults redirects to signin when pinsUser cookie is missing", async () => { + const mod = loadMyPortalSearchResultsPage(); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: {} } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); +}); + +test("myportal searchresults classifies contact lookup failure and redirects to signin", async () => { + let capturedLog = null; + const mod = loadMyPortalSearchResultsPage({ + getPortalLogin: async () => ({ value: [] }), + consoleLogger: (entry) => { + capturedLog = entry; + } + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); + assert.strictEqual(capturedLog.reasonCode, "CONTACT_LOOKUP_FAILED"); +}); + +test("myportal searchresults classifies upstream failure and redirects to signin", async () => { + let capturedLog = null; + const mod = loadMyPortalSearchResultsPage({ + getPortalLogin: async () => { + throw new Error("crm unavailable"); + }, + consoleLogger: (entry) => { + capturedLog = entry; + } + }); + const result = await mod.getServerSideProps({ + query: { q: "CAS" }, + req: { cookies: { pinsUser: "contact-1" } } + }); + assert.strictEqual(result.redirect.destination, "/auth/signin"); + assert.strictEqual(capturedLog.reasonCode, "UPSTREAM_FAILURE"); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 myportal-searchresults-loader-guards tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}