feat(relay): add portal endpoint relayPolicy overrides and contract checks
This commit is contained in:
@@ -1337,3 +1337,35 @@ Validation:
|
|||||||
Follow-ups:
|
Follow-ups:
|
||||||
|
|
||||||
- Optional next slice: apply relayPolicy declarations to remaining relayGet endpoints in coherent batches (portal module/documents/DNS groups) and standardize policy presets in one shared constants module.
|
- Optional next slice: apply relayPolicy declarations to remaining relayGet endpoints in coherent batches (portal module/documents/DNS groups) and standardize policy presets in one shared constants module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CL-036: TASK22242 portal-facing relayPolicy parity (login + module endpoints)
|
||||||
|
|
||||||
|
date: 2026-03-24
|
||||||
|
author: Cline
|
||||||
|
scope: `pages/api/endpoint/{getportallogin_api,getportalloginproxy_api,getportalmoduledetails_api,getportalmoduledetailsproxy_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs`
|
||||||
|
type: change
|
||||||
|
rationale: Complete the next practical relay policy rollout slice by bringing portal-facing login/module endpoints onto explicit per-endpoint timeout/retry posture.
|
||||||
|
impact: Improves predictability and operational tuning for portal-facing relay GET flows without changing API contracts.
|
||||||
|
status: completed
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
|
||||||
|
- Added explicit `relayPolicy` for four portal-facing endpoints:
|
||||||
|
- `getportallogin_api` -> strict/no-retry profile (`maxRetries: 0`, tighter timeout)
|
||||||
|
- `getportalloginproxy_api` -> low-retry account lookup profile (`maxRetries: 1`)
|
||||||
|
- `getportalmoduledetails_api` -> bounded read profile (`maxRetries: 2`)
|
||||||
|
- `getportalmoduledetailsproxy_api` -> bounded read profile (`maxRetries: 2`)
|
||||||
|
- Kept method explicit (`GET`) in each endpoint policy object.
|
||||||
|
- Extended phase21 endpoint tests with relayPolicy propagation assertions for all four endpoints.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (159/159)
|
||||||
|
- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (10/10)
|
||||||
|
- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors)
|
||||||
|
|
||||||
|
Follow-ups:
|
||||||
|
|
||||||
|
- Optional next slice: extract shared relay policy presets into constants to reduce duplication and enforce profile consistency across remaining relayGet endpoints.
|
||||||
|
|||||||
@@ -65,10 +65,19 @@ export default async function ApiProxy(req, res) {
|
|||||||
emailAddress +
|
emailAddress +
|
||||||
"' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
|
"' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
|
||||||
|
|
||||||
|
const relayPolicy = {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 5000,
|
||||||
|
maxRetries: 0,
|
||||||
|
retryBaseDelayMs: 0,
|
||||||
|
retryMaxDelayMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
return relayGet({
|
return relayGet({
|
||||||
queryUrl,
|
queryUrl,
|
||||||
res,
|
res,
|
||||||
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
|
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
|
||||||
|
relayPolicy,
|
||||||
errorResponse: {
|
errorResponse: {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "PORTAL_LOGIN_FETCH_FAILED",
|
code: "PORTAL_LOGIN_FETCH_FAILED",
|
||||||
|
|||||||
@@ -36,10 +36,19 @@ export default async function ApiProxy(req, res) {
|
|||||||
emailAddress +
|
emailAddress +
|
||||||
"'&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
|
"'&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname";
|
||||||
|
|
||||||
|
const relayPolicy = {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 6000,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryBaseDelayMs: 100,
|
||||||
|
retryMaxDelayMs: 500
|
||||||
|
};
|
||||||
|
|
||||||
return relayGet({
|
return relayGet({
|
||||||
queryUrl,
|
queryUrl,
|
||||||
res,
|
res,
|
||||||
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
|
requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken),
|
||||||
|
relayPolicy,
|
||||||
errorResponse: {
|
errorResponse: {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "PORTAL_LOGIN_PROXY_FETCH_FAILED",
|
code: "PORTAL_LOGIN_PROXY_FETCH_FAILED",
|
||||||
|
|||||||
@@ -60,9 +60,18 @@ export default async function ApiProxy(req, res) {
|
|||||||
|
|
||||||
queryUrl = queryUrl + getSelectQuery(appealType);
|
queryUrl = queryUrl + getSelectQuery(appealType);
|
||||||
|
|
||||||
|
const relayPolicy = {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 8000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBaseDelayMs: 150,
|
||||||
|
retryMaxDelayMs: 800
|
||||||
|
};
|
||||||
|
|
||||||
return relayGet({
|
return relayGet({
|
||||||
queryUrl,
|
queryUrl,
|
||||||
res,
|
res,
|
||||||
|
relayPolicy,
|
||||||
errorResponse: {
|
errorResponse: {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "PORTAL_MODULE_DETAILS_FETCH_FAILED",
|
code: "PORTAL_MODULE_DETAILS_FETCH_FAILED",
|
||||||
|
|||||||
@@ -60,9 +60,18 @@ export default async function ApiProxy(req, res) {
|
|||||||
|
|
||||||
queryUrl = queryUrl + getSelectQuery(appealType);
|
queryUrl = queryUrl + getSelectQuery(appealType);
|
||||||
|
|
||||||
|
const relayPolicy = {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 8000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBaseDelayMs: 150,
|
||||||
|
retryMaxDelayMs: 800
|
||||||
|
};
|
||||||
|
|
||||||
return relayGet({
|
return relayGet({
|
||||||
queryUrl,
|
queryUrl,
|
||||||
res,
|
res,
|
||||||
|
relayPolicy,
|
||||||
errorResponse: {
|
errorResponse: {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: "PORTAL_MODULE_DETAILS_PROXY_FETCH_FAILED",
|
code: "PORTAL_MODULE_DETAILS_PROXY_FETCH_FAILED",
|
||||||
|
|||||||
@@ -389,6 +389,39 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getportallogin passes strict relayPolicy overrides to relayGet", async () => {
|
||||||
|
let capturedRelayPolicy = null;
|
||||||
|
|
||||||
|
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
||||||
|
respondError: respondErrorMock,
|
||||||
|
respondSuccess: respondSuccessMock,
|
||||||
|
relayGet: async ({ relayPolicy, res }) => {
|
||||||
|
capturedRelayPolicy = relayPolicy;
|
||||||
|
return respondSuccessMock(res, { value: [] });
|
||||||
|
},
|
||||||
|
hashAPIPath: (input) =>
|
||||||
|
input && input.startsWith("/api/endpoint/getportallogin_api")
|
||||||
|
? "&hash=expected"
|
||||||
|
: "&hash=relay",
|
||||||
|
azureHeadersPaged: () => ({})
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: { emailAddress: "user@test.local", hash: "expected" }
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 5000,
|
||||||
|
maxRetries: 0,
|
||||||
|
retryBaseDelayMs: 0,
|
||||||
|
retryMaxDelayMs: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("getportallogin accepts encoded email hash variant", async () => {
|
test("getportallogin accepts encoded email hash variant", async () => {
|
||||||
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
const mod = loadModule("pages/api/endpoint/getportallogin_api.js", {
|
||||||
respondError: respondErrorMock,
|
respondError: respondErrorMock,
|
||||||
@@ -501,6 +534,33 @@ test("getportalloginproxy catch path returns PORTAL_LOGIN_PROXY_FETCH_FAILED", a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getportalloginproxy passes relayPolicy overrides to relayGet", async () => {
|
||||||
|
let capturedRelayPolicy = null;
|
||||||
|
|
||||||
|
const mod = loadModule("pages/api/endpoint/getportalloginproxy_api.js", {
|
||||||
|
respondError: respondErrorMock,
|
||||||
|
respondSuccess: respondSuccessMock,
|
||||||
|
relayGet: async ({ relayPolicy, res }) => {
|
||||||
|
capturedRelayPolicy = relayPolicy;
|
||||||
|
return respondSuccessMock(res, { value: [] });
|
||||||
|
},
|
||||||
|
azureHeadersPaged: () => ({})
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = { query: { emailAddress: "user@test.local" } };
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 6000,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryBaseDelayMs: 100,
|
||||||
|
retryMaxDelayMs: 500
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("getpersonalaccount returns CONTACT_ID_REQUIRED when contactid missing", async () => {
|
test("getpersonalaccount returns CONTACT_ID_REQUIRED when contactid missing", async () => {
|
||||||
const mod = loadModule("pages/api/endpoint/getpersonalaccount_api.js", {
|
const mod = loadModule("pages/api/endpoint/getpersonalaccount_api.js", {
|
||||||
respondError: respondErrorMock,
|
respondError: respondErrorMock,
|
||||||
@@ -2410,6 +2470,38 @@ test("getportalmoduledetails catch path returns PORTAL_MODULE_DETAILS_FETCH_FAIL
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getportalmoduledetails passes relayPolicy overrides to relayGet", async () => {
|
||||||
|
let capturedRelayPolicy = null;
|
||||||
|
|
||||||
|
const mod = loadModule("pages/api/endpoint/getportalmoduledetails_api.js", {
|
||||||
|
respondError: respondErrorMock,
|
||||||
|
respondSuccess: respondSuccessMock,
|
||||||
|
relayGet: async ({ relayPolicy, res }) => {
|
||||||
|
capturedRelayPolicy = relayPolicy;
|
||||||
|
return respondSuccessMock(res, { value: [] });
|
||||||
|
},
|
||||||
|
getSelectQuery: () => "&$select=pinswg_name"
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
appealType: "pinswg_planningappeals78s",
|
||||||
|
caseReference: "CAS-1"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 8000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBaseDelayMs: 150,
|
||||||
|
retryMaxDelayMs: 800
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("getportalmoduledetailsproxy returns APPEAL_TYPE_REQUIRED when appealType missing", async () => {
|
test("getportalmoduledetailsproxy returns APPEAL_TYPE_REQUIRED when appealType missing", async () => {
|
||||||
const mod = loadModule(
|
const mod = loadModule(
|
||||||
"pages/api/endpoint/getportalmoduledetailsproxy_api.js",
|
"pages/api/endpoint/getportalmoduledetailsproxy_api.js",
|
||||||
@@ -2471,6 +2563,41 @@ test("getportalmoduledetailsproxy catch path returns PORTAL_MODULE_DETAILS_PROXY
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getportalmoduledetailsproxy passes relayPolicy overrides to relayGet", async () => {
|
||||||
|
let capturedRelayPolicy = null;
|
||||||
|
|
||||||
|
const mod = loadModule(
|
||||||
|
"pages/api/endpoint/getportalmoduledetailsproxy_api.js",
|
||||||
|
{
|
||||||
|
respondError: respondErrorMock,
|
||||||
|
respondSuccess: respondSuccessMock,
|
||||||
|
relayGet: async ({ relayPolicy, res }) => {
|
||||||
|
capturedRelayPolicy = relayPolicy;
|
||||||
|
return respondSuccessMock(res, { value: [] });
|
||||||
|
},
|
||||||
|
getSelectQuery: () => "&$select=pinswg_name"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
appealType: "pinswg_planningappeals78s",
|
||||||
|
caseReference: "CAS-1"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
await mod.default(req, res);
|
||||||
|
|
||||||
|
assert.strictEqual(res.state.statusCode, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 8000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBaseDelayMs: 150,
|
||||||
|
retryMaxDelayMs: 800
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("getmylpacases returns LPA_ID_REQUIRED when lpaid missing", async () => {
|
test("getmylpacases returns LPA_ID_REQUIRED when lpaid missing", async () => {
|
||||||
const mod = loadModule("pages/api/endpoint/getmylpacases_api.js", {
|
const mod = loadModule("pages/api/endpoint/getmylpacases_api.js", {
|
||||||
respondError: respondErrorMock,
|
respondError: respondErrorMock,
|
||||||
|
|||||||
Reference in New Issue
Block a user