diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 4a65994b..a8866a71 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -1337,3 +1337,35 @@ Validation: 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. + +--- + +### 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. diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index a6593a9a..8cc050be 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -65,10 +65,19 @@ export default async function ApiProxy(req, res) { emailAddress + "' 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({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, errorResponse: { status: 400, code: "PORTAL_LOGIN_FETCH_FAILED", diff --git a/pages/api/endpoint/getportalloginproxy_api.js b/pages/api/endpoint/getportalloginproxy_api.js index b1c734d1..91dd430c 100644 --- a/pages/api/endpoint/getportalloginproxy_api.js +++ b/pages/api/endpoint/getportalloginproxy_api.js @@ -36,10 +36,19 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname"; + const relayPolicy = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, errorResponse: { status: 400, code: "PORTAL_LOGIN_PROXY_FETCH_FAILED", diff --git a/pages/api/endpoint/getportalmoduledetails_api.js b/pages/api/endpoint/getportalmoduledetails_api.js index 23e44cf6..6c8e66bd 100644 --- a/pages/api/endpoint/getportalmoduledetails_api.js +++ b/pages/api/endpoint/getportalmoduledetails_api.js @@ -60,9 +60,18 @@ export default async function ApiProxy(req, res) { queryUrl = queryUrl + getSelectQuery(appealType); + const relayPolicy = { + method: "GET", + timeoutMs: 8000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 800 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PORTAL_MODULE_DETAILS_FETCH_FAILED", diff --git a/pages/api/endpoint/getportalmoduledetailsproxy_api.js b/pages/api/endpoint/getportalmoduledetailsproxy_api.js index bdb6ba5f..9ee22dae 100644 --- a/pages/api/endpoint/getportalmoduledetailsproxy_api.js +++ b/pages/api/endpoint/getportalmoduledetailsproxy_api.js @@ -60,9 +60,18 @@ export default async function ApiProxy(req, res) { queryUrl = queryUrl + getSelectQuery(appealType); + const relayPolicy = { + method: "GET", + timeoutMs: 8000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 800 + }; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PORTAL_MODULE_DETAILS_PROXY_FETCH_FAILED", diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index c321d3c1..16c1b95e 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -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 () => { const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { 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 () => { const mod = loadModule("pages/api/endpoint/getpersonalaccount_api.js", { 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 () => { const mod = loadModule( "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 () => { const mod = loadModule("pages/api/endpoint/getmylpacases_api.js", { respondError: respondErrorMock,