Merged PR 2665: 25470 testing authentication handler

Related work items: #25470
This commit is contained in:
Robert Bond
2026-11-02 12:41:09 +00:00
parent ce21a1a00f
commit 93eabe8e6d
6 changed files with 581 additions and 37 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ import { consoleLogger } from "../../actions/core/logger";
import { normalizeSpecialistProcess } from "../domain/case-lifecycle";
import {
getFormCollectionByID,
getSearchDetails
getSearchDetails,
getDetails
} from "../../components/utils";
import {
setAccountDetails,
+46 -1
View File
@@ -19,6 +19,40 @@ import { getPortalLogin } from "../../../actions/services/accountService";
const prisma = new PrismaClient();
const createVerificationTokenCompletion = () => {
let complete;
const promise = new Promise((resolve) => {
complete = resolve;
});
return { promise, complete };
};
const createVerificationTokenSynchronizationAdapter = (
verificationTokenCompletion
) => {
const adapter = PrismaAdapter(prisma);
return {
...adapter,
async createVerificationToken(data) {
try {
const result = await adapter.createVerificationToken(data);
verificationTokenCompletion.complete({ result });
return result;
} catch (error) {
verificationTokenCompletion.complete({ error });
throw error;
}
}
};
};
const WELSH_LANGUAGE_CODE = 846040000;
const authDiagnostic = (event, metadata = {}) => {
@@ -167,6 +201,7 @@ const resolveRequestLocale = (req) => {
const authOptions = (req, res) => {
const requestLocale = resolveRequestLocale(req);
const verificationTokenCompletion = createVerificationTokenCompletion();
return {
providers: [
@@ -186,6 +221,13 @@ const authOptions = (req, res) => {
effectiveLocale
});
const tokenCreation =
await verificationTokenCompletion.promise;
if (tokenCreation.error) {
throw tokenCreation.error;
}
return res
.writeHead(200, { "Content-Type": "application/json" })
.json({ url: formURL });
@@ -240,7 +282,10 @@ const authOptions = (req, res) => {
}
})
],
adapter: PrismaAdapter(prisma),
adapter: createVerificationTokenSynchronizationAdapter(
verificationTokenCompletion
),
secret: process.env.NEXTAUTH_SECRET,
session: {
strategy: "database",
+27 -8
View File
@@ -317,11 +317,11 @@ export const getServerSideProps = wrapper.getServerSideProps(
const limitRequests = pLimit(5);
const arr = [];
const list =
detailsType === "mySubmittedReps"
? resultsObj
: resultsObj.value;
const list = resultsObj.value;
// detailsType === "mySubmittedReps"
// ? resultsObj
// : resultsObj.value;
// console.log(list);
const tasks = list.map((item) =>
limitRequests(async () => {
try {
@@ -374,18 +374,37 @@ export const getServerSideProps = wrapper.getServerSideProps(
return arr;
};
const DASHBOARD_LIMIT = 3;
const limitResults = (data) => ({
...data,
value: (data?.value ?? []).slice(0, DASHBOARD_LIMIT)
});
const [
myCasesDetails,
watchedCasesDetails,
mySubmittedRepsDetails,
myRepresentationsDetails
] = await Promise.all([
getDetails(myCases, "myCases"),
getDetails(filteredWatchedCases, "myWatchedCases"),
getDetails(mySubmittedReps, "mySubmittedReps"),
getDetails(limitResults(myCases), "myCases"),
getDetails(limitResults(filteredWatchedCases), "myWatchedCases"),
getDetails(limitResults(mySubmittedReps), "mySubmittedReps"),
getDetails(myRepresentations, "myRepresentations")
]);
// const [
// myCasesDetails,
// watchedCasesDetails,
// mySubmittedRepsDetails,
// myRepresentationsDetails
// ] = await Promise.all([
// getDetails(limitResults(myCases), "myCases"),
// getDetails(limitResults(filteredWatchedCases), "myWatchedCases"),
// getDetails(limitResults(mySubmittedReps), "mySubmittedReps"),
// getDetails(limitResults(myRepresentations), "myRepresentations")
// ]);
store.dispatch(setAccountDetails(accountDetails));
store.dispatch(setLoggedInUserId(loggedInUser));
store.dispatch(setMyCases(myCases));
+327 -10
View File
@@ -11,7 +11,63 @@ import ViewAll from "../../components/myportal/viewall";
import NoSessionWarning from "../../components/nosession";
import TimeOut from "../../components/timeout";
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 { getPortalModuleDetails } from "../../actions/services/caseService";
import {
getAwaitingSubmissionFromBlob,
getRepsFromBlob
} from "../../actions/services/documentService";
import {
getMyCases,
getMyLPACases,
getWatchedCases
} from "../../actions/services/portalService";
import { getBasicSearch } from "../../actions/services/searchService";
import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy";
import { getFormCollectionByID } from "../../components/utils";
import {
setAccountDetails,
setContainerID,
setLoggedInUserId
} from "../../store/accountDetails/action";
import {
setAwaitingSubmissionDetails,
setAwaitingSubmissionFromBlob
} from "../../store/awaitingSubmission/action";
import { setCurrentView } from "../../store/currentView/action";
import { setMyCases, setMyCasesDetails } from "../../store/myCases/action";
import {
setMyRepresentations,
setMyRepresentationsDetails,
setMySubmittedReps,
setMySubmittedRepsDetails
} from "../../store/myRepresentations/action";
import {
setWatchedCases,
setWatchedCasesDetails
} from "../../store/watchedCases/action";
import { setSearchResults } from "../../store/searchOutput/action";
import { setSearch } from "../../store/search/action";
import { wrapper } from "../../store/store";
const Home = (props) => {
const { footerLinks, pages } = props;
let { t, lang } = useTranslation();
@@ -39,7 +95,7 @@ const Home = (props) => {
),
"myRepresentations": t("common:myrepresentations-viewall-title"),
"watchedCases": t("common:watchedcases-viewall-title"),
"mySubmittedReps": t("common:mysubmittedreps-viewall-title"),
"mySubmittedReps": t("common:mysubmittedreps-viewall-title")
};
function whichTitle(key) {
@@ -83,14 +139,275 @@ const Home = (props) => {
);
};
// export const getServerSideProps = wrapper.getServerSideProps(
// (store) =>
// async ({ req, res }) => {
// const searchResultsObj = (await getBasicSearch("View All")) || null;
// store.dispatch(setSearchResults(searchResultsObj));
// store.dispatch(setSearch("View All"));
// }
// );
export const getServerSideProps = wrapper.getServerSideProps(
(store) => async (ctx) => {
const { query } = ctx;
// The View All component uses router.query.key.
const viewKey = Array.isArray(query.key) ? query.key[0] : query.key;
console.log("[ViewAll SSR] Query:", ctx.query);
console.log("[ViewAll SSR] Selected viewKey:", viewKey);
const validViewKeys = [
"myCases",
"watchedCases",
"mySubmittedReps",
"myRepresentations",
"awaitingSubmissionDetails"
];
if (!validViewKeys.includes(viewKey)) {
return {
redirect: {
destination: "/myportal",
permanent: false
}
};
}
// Authenticate before retrieving portal data.
const thisSession = await getSession(ctx);
if (!thisSession?.user?.email) {
return {
redirect: {
destination: "/auth/signin",
permanent: false
}
};
}
const portalLogin = await getPortalLogin(thisSession.user.email);
const loggedInUser = portalLogin?.value?.[0]?.contactid;
if (!loggedInUser) {
return {
redirect: {
destination: "/auth/signin",
permanent: false
}
};
}
const accountDetails = await getPersonalAccount(loggedInUser);
if (!accountDetails || accountDetails.errorCode) {
return {
redirect: {
destination: "/myportal/error",
permanent: false
}
};
}
// Preserve the LPA logic from MyPortal index.
const isLPA = accountDetails.pinswg_typeofinvolvement === 846040012;
const lpaId =
accountDetails[
"pinswg_contact_associatedlpa@OData.Community.Display.V1.FormattedValue"
];
// Full-list detail enrichment.
// No dashboard limit is applied here.
const getDetails = async (resultsObj, detailsType) => {
const limitRequests = pLimit(5);
const list = Array.isArray(resultsObj)
? resultsObj
: (resultsObj?.value ?? []);
const tasks = list.map((item) =>
limitRequests(async () => {
try {
let caseID = "";
switch (detailsType) {
case "myCases":
caseID = item.ticketnumber;
break;
case "myWatchedCases":
case "mySubmittedReps":
caseID =
item[
"_pinswg_watchedcase_value@OData.Community.Display.V1.FormattedValue"
];
break;
case "awaitingSubmission":
caseID = item.ticketnumber;
break;
case "myRepresentations":
caseID = item.casereference;
break;
default:
caseID = item.pinswg_title;
}
if (!item.pinswg_appealcasetype || !caseID) {
return null;
}
const formCollection = getFormCollectionByID(
item.pinswg_appealcasetype
);
if (!formCollection?.LogicalCollectionName) {
console.error(
"Missing collection metadata for appeal type:",
item.pinswg_appealcasetype
);
return null;
}
return (
(await getPortalModuleDetails(
formCollection.LogicalCollectionName,
caseID
)) || {}
);
} catch (err) {
console.error("Error fetching View All details:", err);
return null;
}
})
);
const details = await Promise.all(tasks);
// Retain the original result ordering.
return details.filter(Boolean);
};
// Shared account state.
store.dispatch(setAccountDetails(accountDetails));
store.dispatch(setLoggedInUserId(loggedInUser));
store.dispatch(setContainerID(thisSession.user.id));
// Ensure the rendered View All category agrees
// with the category loaded by this request.
store.dispatch(
setCurrentView({
viewKey,
viewName: viewKey
})
);
switch (viewKey) {
case "myCases": {
const myCases = isLPA
? await getMyLPACases(lpaId)
: await getMyCases(loggedInUser);
const myCasesDetails = await getDetails(myCases, "myCases");
store.dispatch(setMyCases(myCases));
store.dispatch(setMyCasesDetails(myCasesDetails));
break;
}
case "watchedCases": {
const watchedCases = await getWatchedCases(loggedInUser);
const { watchedCases: filteredWatchedCases } =
splitWatchedCasesBySubmissionState(
watchedCases.value ?? []
);
const watchedCasesDetails = await getDetails(
filteredWatchedCases,
"myWatchedCases"
);
store.dispatch(setWatchedCases(filteredWatchedCases));
store.dispatch(setWatchedCasesDetails(watchedCasesDetails));
break;
}
case "mySubmittedReps": {
const watchedCases = await getWatchedCases(loggedInUser);
const { submittedRepresentations: mySubmittedReps } =
splitWatchedCasesBySubmissionState(
watchedCases.value ?? []
);
const mySubmittedRepsDetails = await getDetails(
mySubmittedReps,
"mySubmittedReps"
);
store.dispatch(setMySubmittedReps(mySubmittedReps));
store.dispatch(
setMySubmittedRepsDetails(mySubmittedRepsDetails)
);
break;
}
case "myRepresentations": {
const myRepresentations = await getRepsFromBlob(
thisSession.user.id
);
// The existing View All component reads
// representation display data directly
// from myRepresentations.value.
store.dispatch(setMyRepresentations(myRepresentations));
// Preserve the existing Redux detail
// collection contract.
const myRepresentationsDetails = await getDetails(
myRepresentations,
"myRepresentations"
);
store.dispatch(
setMyRepresentationsDetails(myRepresentationsDetails)
);
break;
}
case "awaitingSubmissionDetails": {
const awaitingSubmissionFromBlob =
await getAwaitingSubmissionFromBlob(thisSession.user.id);
// Awaiting submission already has its
// own Blob-backed data structure.
// Preserve the dashboard's existing
// Redux dispatches.
store.dispatch(
setAwaitingSubmissionFromBlob(awaitingSubmissionFromBlob)
);
store.dispatch(
setAwaitingSubmissionDetails(awaitingSubmissionFromBlob)
);
break;
}
}
// Preserve the original View All search state.
const searchResultsObj = (await getBasicSearch("View All")) || null;
store.dispatch(setSearchResults(searchResultsObj));
store.dispatch(setSearch("View All"));
return {
props: {}
};
}
);
const mapStateToProps = (state) => {
return {
@@ -105,7 +422,7 @@ const mapStateToProps = (state) => {
myRepresentations: state.myRepresentations,
awaitingSubmission: state.awaitingSubmission,
currentView: state.currentView,
mySubmittedReps: state.myRepresentations.mySubmittedReps,
mySubmittedReps: state.myRepresentations.mySubmittedReps
};
};
+15 -15
View File
@@ -31,7 +31,7 @@ const appReducer = combineReducers({
myCases,
myRepresentations,
watchedCases,
form: formReducer,
form: formReducer
});
const combinedReducer = (state, action) => {
@@ -53,28 +53,28 @@ const reducer = (state = {}, action) => {
...state,
server: {
...state.server,
progress: action.payload,
},
progress: action.payload
}
};
case "CLIENT_ACTION":
return {
...state,
client: {
...state.client,
progress: action.payload,
},
progress: action.payload
}
};
case "GET_ITEMS":
return {
...state,
items: action.payload,
loading: false,
loading: false
};
case "SET_ITEMS":
return {
...state,
items: action.payload,
loading: false,
loading: false
};
default:
return combinedReducer(state, action);
@@ -84,7 +84,7 @@ const reducer = (state = {}, action) => {
const migrations = {
1: (state) => {
return {
...state,
...state
};
},
2: (state) => {
@@ -93,12 +93,12 @@ const migrations = {
const newStateObj = {
...state,
"currentView": {},
"searchResultsObj": {},
"searchResultsObj": {}
};
console.info("v2 schema migration:" + JSON.stringify(newStateObj));
return newStateObj;
},
}
};
// BINDING MIDDLEWARE
@@ -119,7 +119,7 @@ const makeStore = ({ isServer }) => {
const {
persistStore,
persistReducer,
createMigrate,
createMigrate
} = require("redux-persist");
const storage = require("redux-persist/lib/storage").default;
@@ -132,15 +132,15 @@ const makeStore = ({ isServer }) => {
"LPAData",
"accountDetails",
"awaitingSubmission",
"myCases",
// "myCases",
"myRepresentations",
"watchedCases",
"form",
"form"
],
version: process.env.BUILD_ID_ENV,
storage: storage,
stateReconciler: autoMergeLevel2, // if needed, use a safer storage
migrate: createMigrate(migrations, { debug: true }),
migrate: createMigrate(migrations, { debug: true })
};
const persistedReducer = persistReducer(persistConfig, reducer); // Create a new reducer with our existing reducer
@@ -157,7 +157,7 @@ const makeStore = ({ isServer }) => {
};
export const setClientState = (clientState) => ({
type: SET_CLIENT_STATE,
payload: clientState,
payload: clientState
});
// Export the wrapper & wrap the pages/_app.js with this wrapper only
export const wrapper = createWrapper(makeStore);
+164 -2
View File
@@ -6,7 +6,7 @@ const vm = require("vm");
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
const loadAuthInternals = () => {
const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => {
const filePath = path.join(
__dirname,
"..",
@@ -49,12 +49,14 @@ const loadAuthInternals = () => {
NEXTAUTH_SECRET: "test-secret"
}
},
PrismaAdapter: () => ({}),
PrismaAdapter: () => adapterFactory(),
PrismaClient: function PrismaClient() {
return {};
},
NextAuth: () => ({}),
EmailProvider: () => ({}),
getPortalLogin: async () => ({ value: [] }),
redactSensitive: (value) => value,
consoleLogger: () => {},
console: {
log: () => {},
@@ -68,6 +70,42 @@ const loadAuthInternals = () => {
return context.module.exports;
};
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const createResponse = () => {
const calls = [];
const response = {
writeHead(status, headers) {
calls.push({ type: "writeHead", status, headers });
return response;
},
json(body) {
calls.push({ type: "json", body });
return response;
}
};
return { response, calls };
};
const createEmailApiRequest = () => ({
query: { locale: "en" },
body: {},
cookies: {}
});
const verificationUrl = (token) =>
`https://english.example/api/auth/callback/emailAPI?callbackUrl=https%3A%2F%2Fenglish.example&token=${token}&email=user%40example.com`;
test("auth/resolveRequestLocale prefers query then body then cookie then default", async () => {
const mod = loadAuthInternals();
@@ -144,6 +182,130 @@ test("auth/redirect callback rewrites external URL to locale-safe base origin",
assert.strictEqual(parsed.searchParams.get("token"), "abc");
});
test("auth/emailAPI waits for successful token persistence before returning its URL", async () => {
const persistence = deferred();
const mod = loadAuthInternals({
adapterFactory: () => ({
createVerificationToken: () => persistence.promise
})
});
const { response, calls } = createResponse();
const options = mod.authOptions(createEmailApiRequest(), response);
const provider = options.providers[0];
const sendPromise = provider.sendVerificationRequest({
identifier: "user@example.com",
url: verificationUrl("token-one")
});
const createPromise = options.adapter.createVerificationToken({
identifier: "user@example.com",
token: "stored-token-one",
expires: new Date(Date.now() + 60_000)
});
await Promise.resolve();
await Promise.resolve();
assert.deepStrictEqual(calls, []);
persistence.resolve({
identifier: "user@example.com",
token: "stored-token-one",
expires: new Date(Date.now() + 60_000)
});
await Promise.all([sendPromise, createPromise]);
assert.strictEqual(calls[0].type, "writeHead");
assert.strictEqual(calls[0].status, 200);
assert.strictEqual(calls[1].type, "json");
assert.strictEqual(calls[1].body.url, verificationUrl("token-one"));
});
test("auth/emailAPI does not return a verification URL when persistence fails", async () => {
const persistence = deferred();
const persistenceError = new Error("persistence failed");
const mod = loadAuthInternals({
adapterFactory: () => ({
createVerificationToken: () => persistence.promise
})
});
const { response, calls } = createResponse();
const options = mod.authOptions(createEmailApiRequest(), response);
const sendPromise = options.providers[0].sendVerificationRequest({
identifier: "user@example.com",
url: verificationUrl("token-two")
});
const createPromise = options.adapter.createVerificationToken({
identifier: "user@example.com",
token: "stored-token-two",
expires: new Date(Date.now() + 60_000)
});
persistence.reject(persistenceError);
const [sendResult, createResult] = await Promise.allSettled([
sendPromise,
createPromise
]);
assert.strictEqual(sendResult.status, "rejected");
assert.strictEqual(sendResult.reason, persistenceError);
assert.strictEqual(createResult.status, "rejected");
assert.strictEqual(createResult.reason, persistenceError);
assert.deepStrictEqual(calls, []);
});
test("auth/emailAPI keeps concurrent sign-in persistence signals request-scoped", async () => {
const firstPersistence = deferred();
const secondPersistence = deferred();
const adapters = [
{ createVerificationToken: () => firstPersistence.promise },
{ createVerificationToken: () => secondPersistence.promise }
];
const mod = loadAuthInternals({
adapterFactory: () => adapters.shift()
});
const firstResponse = createResponse();
const secondResponse = createResponse();
const firstOptions = mod.authOptions(
createEmailApiRequest(),
firstResponse.response
);
const secondOptions = mod.authOptions(
createEmailApiRequest(),
secondResponse.response
);
const firstSend = firstOptions.providers[0].sendVerificationRequest({
identifier: "user@example.com",
url: verificationUrl("first-token")
});
const firstCreate = firstOptions.adapter.createVerificationToken({});
const secondSend = secondOptions.providers[0].sendVerificationRequest({
identifier: "user@example.com",
url: verificationUrl("second-token")
});
const secondCreate = secondOptions.adapter.createVerificationToken({});
secondPersistence.resolve({ token: "stored-second-token" });
await Promise.all([secondSend, secondCreate]);
assert.deepStrictEqual(firstResponse.calls, []);
assert.strictEqual(
secondResponse.calls[1].body.url,
verificationUrl("second-token")
);
firstPersistence.resolve({ token: "stored-first-token" });
await Promise.all([firstSend, firstCreate]);
assert.strictEqual(
firstResponse.calls[1].body.url,
verificationUrl("first-token")
);
});
const run = async () => {
let passed = 0;