diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 097f10da..51bf914b 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -1222,3 +1222,225 @@ Validation: Follow-ups: - Optional next iteration: add a PR template block in Azure DevOps mirroring the runbook governance gate checklist. + +--- + +### CL-033: TASK22242 relay telemetry enrichment (lifecycle events + correlation fields) + +date: 2026-03-24 +author: Cline +scope: `pages/api/middleware/relayForwarding.js`, `tests/phase21/relay-forwarding-hardening.test.cjs` +type: change +rationale: Add richer relay observability so operations can correlate retries and outcomes per request and track latency/status patterns without changing endpoint contracts. +impact: Improves operational diagnostics and trend analysis for relay traffic while preserving existing API behavior. +status: completed + +Summary: + +- Enriched relay middleware telemetry with request lifecycle events: + - `relay_request_started` + - `relay_request_retrying` + - `relay_request_succeeded` + - `relay_request_failed` +- Added shared telemetry fields for correlation and analysis: + - `relayRequestId` (per request correlation id) + - `attemptsMade`, `retryCountUsed`, `remainingRetries` + - `elapsedMs` + - `statusClass` (`2xx/4xx/5xx` style buckets) + - resolved runtime knobs included at start event +- Kept existing retry policy and endpoint response contracts unchanged. +- Expanded phase21 relay hardening tests to assert telemetry behavior: + - started/retrying/succeeded event presence + - failed event telemetry fields + - stable `relayRequestId` across lifecycle events for one request + +Validation: + +- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (7/7) +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (152/152) +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- Optional next step: map these lifecycle fields into central dashboards/alerts (retry rate, status-class distribution, p95 elapsedMs). + +--- + +### CL-034: TASK22242 per-endpoint relay overrides + idempotency-aware retry gating + +date: 2026-03-24 +author: Cline +scope: `pages/api/middleware/relayForwarding.js`, `tests/phase21/relay-forwarding-hardening.test.cjs` +type: change +rationale: Deliver the next functional relay enhancement by enabling route-level retry tuning while adding safe-by-default retry gating for non-idempotent methods. +impact: Improves control and safety of relay retries without breaking existing endpoint contracts. +status: completed + +Summary: + +- Added **relay policy override support** (`relayPolicy`) to shared relay helpers (`relayGet`, `relayGetData`, `forwardGetData`): + - per-call override of `timeoutMs`, `maxRetries`, `retryBaseDelayMs`, `retryMaxDelayMs` + - optional method override via `relayPolicy.method` +- Added **idempotency-aware retry gating scaffolding**: + - retries allowed by default only for idempotent methods (`GET`, `HEAD`, `OPTIONS`) + - non-idempotent retry behavior controlled by: + - env flag `RELAY_ALLOW_NON_IDEMPOTENT_RETRIES` (default false) + - per-call override `relayPolicy.allowNonIdempotentRetries` +- Extended relay telemetry fields to include method and non-idempotent policy posture in start/failure/retry events. +- Preserved existing route behavior: + - existing GET endpoint flows continue to use retries per configured bounds + - no endpoint response contract changes + +Validation: + +- `node tests/phase21/relay-forwarding-hardening.test.cjs` -> pass (10/10) +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (152/152) +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- Future non-GET relay adoption should explicitly opt in/out per route using `relayPolicy` and include targeted negative-path tests. + +--- + +### CL-035: TASK22242 apply relayPolicy overrides across broader endpoint cluster + +date: 2026-03-24 +author: Cline +scope: `pages/api/endpoint/{getaccounts_api,getemailaccountcheck_api,getpreferredlanguage_api,getpersonalaccount_api,getlogin_api,getbasicsearch_api,getbasicsearchpaged_api,getadvancedsearch_api,getadvancedsearchpaged_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs` +type: change +rationale: Expand practical adoption of per-endpoint relay policy tuning so high-traffic account/login/search handlers explicitly declare timeout/retry posture rather than relying only on global defaults. +impact: Better operational control and predictable retry behavior per endpoint cluster, with no API contract changes. +status: completed + +Summary: + +- Added explicit `relayPolicy` usage to a broader endpoint set: + - account/login: `getaccounts`, `getemailaccountcheck`, `getpreferredlanguage`, `getpersonalaccount`, `getlogin` + - search: `getbasicsearch`, `getbasicsearchpaged`, `getadvancedsearch`, `getadvancedsearchpaged` +- Applied conservative policy profiles by flow: + - login endpoint (`getlogin`): no retries (`maxRetries: 0`) and tighter timeout + - account lookup endpoints: low retry posture (`maxRetries: 1`) + - search endpoints: bounded retry posture (`maxRetries: 2`) for transient resilience +- Kept method explicit as `GET` in policy for clarity and future-proofing. +- Extended phase21 endpoint tests with relayPolicy propagation assertions: + - `getaccounts` relayPolicy pass-through + - `getlogin` strict relayPolicy pass-through + - `getbasicsearchpaged` relayPolicy pass-through + +Validation: + +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (155/155) +- `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: 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. + +--- + +### CL-037: TASK22242 portal/my-cases relayPolicy parity (my portal + representations) + +date: 2026-03-24 +author: Cline +scope: `pages/api/endpoint/{getmycases_api,getmyrepresentations_api,getwatchedcases_api,getawaitingsubmission_api,getrepresentations_api}.js`, `tests/phase21/endpoint-handler-contract.test.cjs` +type: change +rationale: Continue the branch-by-branch relay policy rollout by applying explicit policy posture to core my-portal retrieval endpoints. +impact: Improves consistency and operational predictability of relay behavior for portal case/representation listing flows without changing endpoint contracts. +status: completed + +Summary: + +- Added explicit `relayPolicy` declarations to: + - `getmycases_api` + - `getmyrepresentations_api` + - `getwatchedcases_api` + - `getawaitingsubmission_api` + - `getrepresentations_api` +- Applied bounded read profile across the batch: + - `method: "GET"` + - `timeoutMs: 8000` + - `maxRetries: 2` + - `retryBaseDelayMs: 150` + - `retryMaxDelayMs: 800` +- Extended phase21 endpoint contract tests with relayPolicy pass-through assertions for each endpoint. + +Validation: + +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (164/164) +- `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 a constants module and reference them from all relayGet endpoints to reduce duplication. + +--- + +### CL-038: TASK22242 P2-S3 closure slice — shared relay policy presets extraction + +date: 2026-03-24 +author: Cline +scope: `pages/api/middleware/relayPolicyPresets.js`, `pages/api/endpoint/{getaccounts_api,getemailaccountcheck_api,getpreferredlanguage_api,getpersonalaccount_api,getlogin_api,getportallogin_api,getportalloginproxy_api,getbasicsearch_api,getbasicsearchpaged_api,getadvancedsearch_api,getadvancedsearchpaged_api,getportalmoduledetails_api,getportalmoduledetailsproxy_api,getmycases_api,getmyrepresentations_api,getawaitingsubmission_api,getrepresentations_api,getwatchedcases_api}.js`, `tests/phase21/_shared.cjs` +type: change +rationale: Complete the planned P2-S3 final maintainability slice by centralizing repeated relay timeout/retry policy objects into shared presets used consistently across all targeted relayGet endpoints. +impact: Eliminates duplicated policy literals, reduces drift risk, and preserves endpoint contracts/behavior by reusing equivalent policy values. +status: completed + +Summary: + +- Added new middleware constants module: + - `pages/api/middleware/relayPolicyPresets.js` + - `RELAY_POLICY_STRICT_LOGIN` + - `RELAY_POLICY_LOOKUP` + - `RELAY_POLICY_BOUNDED_READ` + - `RELAY_POLICY_SEARCH_PAGED` +- Refactored 18 relayGet endpoints to import and use shared presets instead of inline `relayPolicy` object literals: + - lookup profile: `getaccounts`, `getemailaccountcheck`, `getpreferredlanguage`, `getpersonalaccount`, `getportalloginproxy` + - strict login profile: `getlogin`, `getportallogin` + - bounded read profile: `getbasicsearch`, `getportalmoduledetails`, `getportalmoduledetailsproxy`, `getmycases`, `getmyrepresentations`, `getawaitingsubmission`, `getrepresentations`, `getwatchedcases` + - search paged profile: `getbasicsearchpaged`, `getadvancedsearch`, `getadvancedsearchpaged` +- Updated phase21 VM test harness (`tests/phase21/_shared.cjs`) to inject preset constants so endpoint contract tests continue to execute with import-stripped modules. + +Validation: + +- `node tests/phase21/endpoint-handler-contract.test.cjs` -> pass (164/164) +- `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: + +- P2-S3 planned slices are now complete; no further mandatory relay policy rollout slices remain for this stream. diff --git a/pages/api/endpoint/getaccounts_api.js b/pages/api/endpoint/getaccounts_api.js index f1316370..1abaeead 100644 --- a/pages/api/endpoint/getaccounts_api.js +++ b/pages/api/endpoint/getaccounts_api.js @@ -17,6 +17,7 @@ */ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const emailAddress = req.query.emailAddress; @@ -34,9 +35,12 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1, contactid"; + const relayPolicy = RELAY_POLICY_LOOKUP; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "ACCOUNTS_FETCH_FAILED", diff --git a/pages/api/endpoint/getadvancedsearch_api.js b/pages/api/endpoint/getadvancedsearch_api.js index e90592b5..2299b28d 100644 --- a/pages/api/endpoint/getadvancedsearch_api.js +++ b/pages/api/endpoint/getadvancedsearch_api.js @@ -25,6 +25,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers"; import { consoleLogger } from "../../../actions/core/logger"; import { respondError, respondSuccess } from "../middleware/apiResponse"; import { relayGet, relayGetData } from "../middleware/relayForwarding"; +import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const encodedSearchString = req.query.searchstring; @@ -103,10 +104,13 @@ export default async function ApiProxy(req, res) { queryString + " pinswg_appealcasetype ne null and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true"; + const relayPolicy = RELAY_POLICY_SEARCH_PAGED; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, transformData: async (data, accessToken) => { if (Object.prototype.hasOwnProperty.call(data, "@odata.nextLink")) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getadvancedsearchpaged_api.js b/pages/api/endpoint/getadvancedsearchpaged_api.js index f62a21ef..35df304a 100644 --- a/pages/api/endpoint/getadvancedsearchpaged_api.js +++ b/pages/api/endpoint/getadvancedsearchpaged_api.js @@ -51,6 +51,7 @@ import _ from "lodash"; import { azureHeadersPagedCustom } from "../../../actions/core/headers"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const rawSearchString = req.query.searchstring; @@ -173,11 +174,14 @@ export default async function ApiProxy(req, res) { : ""); } + const relayPolicy = RELAY_POLICY_SEARCH_PAGED; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPagedCustom(accessToken, showNumberOfRecords), + relayPolicy, transformData: (data) => { let dataStr; _.has(data, "@odata.nextLink") === true && diff --git a/pages/api/endpoint/getawaitingsubmission_api.js b/pages/api/endpoint/getawaitingsubmission_api.js index b48bf1fc..54ba4258 100644 --- a/pages/api/endpoint/getawaitingsubmission_api.js +++ b/pages/api/endpoint/getawaitingsubmission_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const loggedInUserId = req.query.loggedInUserId; @@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) { loggedInUserId + " and servicestage eq 1 and pinswg_appealcasetype ne null&$orderby=createdon desc&$count=true"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, transformData: (data) => { data.value.forEach(function (element) { element.pinswg_title = element.title; diff --git a/pages/api/endpoint/getbasicsearch_api.js b/pages/api/endpoint/getbasicsearch_api.js index 61726fc7..d60a696f 100644 --- a/pages/api/endpoint/getbasicsearch_api.js +++ b/pages/api/endpoint/getbasicsearch_api.js @@ -18,6 +18,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const searchString = req.query.searchString; @@ -45,10 +46,13 @@ export default async function ApiProxy(req, res) { : "") + "and pinswg_publishtoweb eq true&$orderby=createdon desc&$count=true"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPaged(accessToken), + relayPolicy, transformData: (data) => { if (Object.prototype.hasOwnProperty.call(data, "@odata.nextLink")) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getbasicsearchpaged_api.js b/pages/api/endpoint/getbasicsearchpaged_api.js index 0da70548..4652aa50 100644 --- a/pages/api/endpoint/getbasicsearchpaged_api.js +++ b/pages/api/endpoint/getbasicsearchpaged_api.js @@ -46,6 +46,7 @@ import _ from "lodash"; import { azureHeadersPagedCustom } from "../../../actions/core/headers"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_SEARCH_PAGED } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { let searchString = req.query.searchString; @@ -111,11 +112,14 @@ export default async function ApiProxy(req, res) { ? "&$skiptoken=" + '' : ""); + const relayPolicy = RELAY_POLICY_SEARCH_PAGED; + return relayGet({ queryUrl, res, requestOptionsBuilder: (accessToken) => azureHeadersPagedCustom(accessToken, showNumberOfRecords), + relayPolicy, transformData: (data) => { if (_.has(data, "@odata.nextLink") === true) { const dataStr = JSON.stringify(data["@odata.nextLink"]); diff --git a/pages/api/endpoint/getemailaccountcheck_api.js b/pages/api/endpoint/getemailaccountcheck_api.js index 1751d5eb..97a0b6ce 100644 --- a/pages/api/endpoint/getemailaccountcheck_api.js +++ b/pages/api/endpoint/getemailaccountcheck_api.js @@ -17,6 +17,7 @@ */ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const emailAddress = req.query.emailAddress; @@ -34,9 +35,12 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1, contactid"; + const relayPolicy = RELAY_POLICY_LOOKUP; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "EMAIL_ACCOUNT_CHECK_FAILED", diff --git a/pages/api/endpoint/getlogin_api.js b/pages/api/endpoint/getlogin_api.js index cf828194..3e00ea8a 100644 --- a/pages/api/endpoint/getlogin_api.js +++ b/pages/api/endpoint/getlogin_api.js @@ -24,6 +24,7 @@ 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; @@ -52,10 +53,13 @@ export default async function ApiProxy(req, res) { 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", diff --git a/pages/api/endpoint/getmycases_api.js b/pages/api/endpoint/getmycases_api.js index c5e521bc..96e9d00a 100644 --- a/pages/api/endpoint/getmycases_api.js +++ b/pages/api/endpoint/getmycases_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const loggedInUserId = req.query.loggedInUserId; @@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) { loggedInUserId + " and pinswg_appealcasetype ne null&$orderby=createdon desc&$count=true"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, transformData: (data) => { data.value.forEach(function (element) { element.pinswg_title = element.title; diff --git a/pages/api/endpoint/getmyrepresentations_api.js b/pages/api/endpoint/getmyrepresentations_api.js index 0b4d738e..b933b614 100644 --- a/pages/api/endpoint/getmyrepresentations_api.js +++ b/pages/api/endpoint/getmyrepresentations_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const loggedInUserId = req.query.loggedInUserId; @@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) { loggedInUserId + "&$count=true&$orderby=createdon desc"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "MY_REPRESENTATIONS_FETCH_FAILED", diff --git a/pages/api/endpoint/getpersonalaccount_api.js b/pages/api/endpoint/getpersonalaccount_api.js index 56039c90..41a70be1 100644 --- a/pages/api/endpoint/getpersonalaccount_api.js +++ b/pages/api/endpoint/getpersonalaccount_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const contactid = req.query.contactid; @@ -36,9 +37,12 @@ export default async function ApiProxy(req, res) { contactid + ")?$select=firstname, lastname, emailaddress1, telephone1, company, address1_line1,address1_line2,address1_city, address1_county,address1_postalcode,pinswg_typeofinvolvement,pinswg_contact_associatedlpa,pinswg_preferredlanguage&$count=true"; + const relayPolicy = RELAY_POLICY_LOOKUP; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PERSONAL_ACCOUNT_FETCH_FAILED", diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index a6593a9a..3e7e3a97 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -20,6 +20,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers"; import { hashAPIPath } from "../../../actions/core/hash"; 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; @@ -65,10 +66,13 @@ export default async function ApiProxy(req, res) { emailAddress + "' and statuscode eq 1&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname"; + const relayPolicy = RELAY_POLICY_STRICT_LOGIN; + 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..52f0b898 100644 --- a/pages/api/endpoint/getportalloginproxy_api.js +++ b/pages/api/endpoint/getportalloginproxy_api.js @@ -19,6 +19,7 @@ import { azureHeadersPaged } from "../../../actions/core/headers"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const emailAddress = req.query.emailAddress; @@ -36,10 +37,13 @@ export default async function ApiProxy(req, res) { emailAddress + "'&$count=true&$select=emailaddress1,contactid,yomifullname,firstname,lastname"; + const relayPolicy = RELAY_POLICY_LOOKUP; + 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..cc2d010f 100644 --- a/pages/api/endpoint/getportalmoduledetails_api.js +++ b/pages/api/endpoint/getportalmoduledetails_api.js @@ -26,6 +26,7 @@ import { getSelectQuery } from "../../../actions/selectQueryTypes"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const appealType = req.query.appealType; @@ -60,9 +61,12 @@ export default async function ApiProxy(req, res) { queryUrl = queryUrl + getSelectQuery(appealType); + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + 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..5ba7d4c9 100644 --- a/pages/api/endpoint/getportalmoduledetailsproxy_api.js +++ b/pages/api/endpoint/getportalmoduledetailsproxy_api.js @@ -26,6 +26,7 @@ import { getSelectQuery } from "../../../actions/selectQueryTypes"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const appealType = req.query.appealType; @@ -60,9 +61,12 @@ export default async function ApiProxy(req, res) { queryUrl = queryUrl + getSelectQuery(appealType); + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PORTAL_MODULE_DETAILS_PROXY_FETCH_FAILED", diff --git a/pages/api/endpoint/getpreferredlanguage_api.js b/pages/api/endpoint/getpreferredlanguage_api.js index 0051dc77..cdad1642 100644 --- a/pages/api/endpoint/getpreferredlanguage_api.js +++ b/pages/api/endpoint/getpreferredlanguage_api.js @@ -22,6 +22,7 @@ import { } from "../../../actions/core/guards"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_LOOKUP } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const emailAddress = sanitizeString(req.query.emailAddress); @@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) { escapeODataString(emailAddress) + "'&$count=true&$select=pinswg_preferredlanguage,contactid"; + const relayPolicy = RELAY_POLICY_LOOKUP; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "PREFERRED_LANGUAGE_FETCH_FAILED", diff --git a/pages/api/endpoint/getrepresentations_api.js b/pages/api/endpoint/getrepresentations_api.js index 9d8eb54b..c3c1f4a0 100644 --- a/pages/api/endpoint/getrepresentations_api.js +++ b/pages/api/endpoint/getrepresentations_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const incidentID = req.query.incidentID; @@ -36,9 +37,12 @@ export default async function ApiProxy(req, res) { incidentID + " and pinswg_publishtoweb eq true&$count=true&$orderby=createdon desc"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, errorResponse: { status: 400, code: "REPRESENTATIONS_FETCH_FAILED", diff --git a/pages/api/endpoint/getwatchedcases_api.js b/pages/api/endpoint/getwatchedcases_api.js index ed90cf73..61039107 100644 --- a/pages/api/endpoint/getwatchedcases_api.js +++ b/pages/api/endpoint/getwatchedcases_api.js @@ -19,6 +19,7 @@ import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; +import { RELAY_POLICY_BOUNDED_READ } from "../middleware/relayPolicyPresets"; export default async function ApiProxy(req, res) { const loggedInUserId = req.query.loggedInUserId; @@ -39,9 +40,12 @@ export default async function ApiProxy(req, res) { loggedInUserId + "&$select=pinswg_emailnotifications,modifiedon,pinswg_appealcasetype,pinswg_watchlistid,_pinswg_watchedcase_value,statuscode,pinswg_representationsubmitted,pinswg_representationtype&$count=true&$orderby=createdon desc&$expand=pinswg_WatchedCase($select=pinswg_AssociatedLPA)"; + const relayPolicy = RELAY_POLICY_BOUNDED_READ; + return relayGet({ queryUrl, res, + relayPolicy, transformData: (data) => { data.value.forEach(function (element) { element.ticketnumber = element.pinswg_WatchedCase?.ticketnumber; diff --git a/pages/api/middleware/relayForwarding.js b/pages/api/middleware/relayForwarding.js index b420186b..420eab3a 100644 --- a/pages/api/middleware/relayForwarding.js +++ b/pages/api/middleware/relayForwarding.js @@ -13,6 +13,7 @@ const DEFAULT_TIMEOUT_MS = 8000; const DEFAULT_MAX_RETRIES = 2; const DEFAULT_RETRY_BASE_DELAY_MS = 200; const DEFAULT_RETRY_MAX_DELAY_MS = 1200; +const DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES = false; const MAX_TIMEOUT_MS = 30000; const MAX_RETRIES = 4; const MAX_RETRY_DELAY_MS = 5000; @@ -29,11 +30,24 @@ const RETRYABLE_ERROR_CODES = new Set([ "EPIPE" ]); +const IDEMPOTENT_RETRY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + const parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; +const parseBoolean = (value, fallback) => { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + + return fallback; +}; + const clamp = (value, min, max) => { return Math.min(max, Math.max(min, value)); }; @@ -65,6 +79,24 @@ const resolveNumericOverride = ({ return clamp(Math.floor(overrideValue), lowerBound, max); }; +const resolveBooleanOverride = ({ overrideValue, fallbackValue }) => { + if (typeof overrideValue === "boolean") return overrideValue; + return fallbackValue; +}; + +const resolveRelayMethod = (value) => { + if (typeof value !== "string" || value.trim().length === 0) { + return "GET"; + } + + return value.trim().toUpperCase(); +}; + +const canRetryForMethod = ({ method, allowNonIdempotentRetries }) => { + if (allowNonIdempotentRetries) return true; + return IDEMPOTENT_RETRY_METHODS.has(method); +}; + const getRelayConfig = () => { return { timeoutMs: sanitizeNumberConfig({ @@ -93,7 +125,11 @@ const getRelayConfig = () => { min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true - }) + }), + allowNonIdempotentRetries: parseBoolean( + process.env.RELAY_ALLOW_NON_IDEMPOTENT_RETRIES, + DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES + ) }; }; @@ -109,8 +145,28 @@ const structuredRelayLog = (event, payload) => { console.info(event, redactSensitive(payload)); }; -const shouldRetryRelayError = ({ error, attempt, maxRetries }) => { +const buildRelayRequestId = () => { + return `relay_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +}; + +const getElapsedMs = (startedAtMs) => { + return Math.max(0, Date.now() - startedAtMs); +}; + +const getStatusClass = (status) => { + if (typeof status !== "number") return "none"; + return `${Math.floor(status / 100)}xx`; +}; + +const shouldRetryRelayError = ({ + error, + attempt, + maxRetries, + method, + allowNonIdempotentRetries +}) => { if (attempt >= maxRetries) return false; + if (!canRetryForMethod({ method, allowNonIdempotentRetries })) return false; const status = error?.response?.status; if (NON_RETRYABLE_STATUS_CODES.has(status)) return false; @@ -126,12 +182,14 @@ export const relayGet = async ({ res, errorResponse, transformData, - requestOptionsBuilder + requestOptionsBuilder, + relayPolicy }) => { try { const { data, accessToken } = await relayGetData({ queryUrl, - requestOptionsBuilder + requestOptionsBuilder, + relayPolicy }); return respondSuccess( @@ -151,12 +209,14 @@ export const relayGet = async ({ export const relayGetData = async ({ queryUrl, requestOptionsBuilder, - accessToken + accessToken, + relayPolicy }) => { return forwardGetData({ queryUrl, requestOptionsBuilder, - accessToken + accessToken, + relayPolicy }); }; @@ -169,19 +229,26 @@ export const forwardGetData = async ({ timeoutMs, maxRetries, retryBaseDelayMs, - retryMaxDelayMs + retryMaxDelayMs, + method, + relayPolicy }) => { const relayConfig = getRelayConfig(); + const relayPolicyConfig = relayPolicy || {}; + + const resolvedMethod = resolveRelayMethod( + relayPolicyConfig.method || method + ); const resolvedTimeoutMs = resolveNumericOverride({ - overrideValue: timeoutMs, + overrideValue: relayPolicyConfig.timeoutMs ?? timeoutMs, fallbackValue: relayConfig.timeoutMs, min: 100, max: MAX_TIMEOUT_MS }); const resolvedMaxRetries = resolveNumericOverride({ - overrideValue: maxRetries, + overrideValue: relayPolicyConfig.maxRetries ?? maxRetries, fallbackValue: relayConfig.maxRetries, min: 0, max: MAX_RETRIES, @@ -189,7 +256,7 @@ export const forwardGetData = async ({ }); const resolvedRetryBaseDelayMs = resolveNumericOverride({ - overrideValue: retryBaseDelayMs, + overrideValue: relayPolicyConfig.retryBaseDelayMs ?? retryBaseDelayMs, fallbackValue: relayConfig.retryBaseDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, @@ -197,13 +264,20 @@ export const forwardGetData = async ({ }); const resolvedRetryMaxDelayMs = resolveNumericOverride({ - overrideValue: retryMaxDelayMs, + overrideValue: relayPolicyConfig.retryMaxDelayMs ?? retryMaxDelayMs, fallbackValue: relayConfig.retryMaxDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }); + const resolvedAllowNonIdempotentRetries = resolveBooleanOverride({ + overrideValue: + relayPolicyConfig.allowNonIdempotentRetries ?? + relayPolicyConfig.allowRetriesForNonIdempotent, + fallbackValue: relayConfig.allowNonIdempotentRetries + }); + const tokenAccessToken = typeof accessToken === "string" && accessToken.length > 0 ? accessToken @@ -226,11 +300,37 @@ export const forwardGetData = async ({ : resolvedTimeoutMs }; + const relayRequestId = buildRelayRequestId(); + const startedAtMs = Date.now(); + + structuredRelayLog("relay_request_started", { + relayRequestId, + queryUrl, + baseUrl: resolvedBaseUrl, + method: resolvedMethod, + timeoutMs: resolvedTimeoutMs, + maxRetries: resolvedMaxRetries, + retryBaseDelayMs: resolvedRetryBaseDelayMs, + retryMaxDelayMs: resolvedRetryMaxDelayMs, + allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries + }); + let lastError; for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) { try { - const { data } = await axios.get(finalUrl, axiosOptions); + const { data, status } = await axios.get(finalUrl, axiosOptions); + + structuredRelayLog("relay_request_succeeded", { + relayRequestId, + queryUrl, + baseUrl: resolvedBaseUrl, + status, + statusClass: getStatusClass(status), + attemptsMade: attempt + 1, + retryCountUsed: attempt, + elapsedMs: getElapsedMs(startedAtMs) + }); return { data, @@ -242,19 +342,27 @@ export const forwardGetData = async ({ const retryEligible = shouldRetryRelayError({ error, attempt, - maxRetries: resolvedMaxRetries + maxRetries: resolvedMaxRetries, + method: resolvedMethod, + allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries }); if (!retryEligible) { error.__relayAlreadyLogged = true; structuredRelayLog("relay_request_failed", { + relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, + method: resolvedMethod, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, + attemptsMade: attempt + 1, + retryCountUsed: attempt, status: error?.response?.status, + statusClass: getStatusClass(error?.response?.status), code: error?.code, - message: error?.message + message: error?.message, + elapsedMs: getElapsedMs(startedAtMs) }); throw error; } @@ -266,14 +374,21 @@ export const forwardGetData = async ({ }); structuredRelayLog("relay_request_retrying", { + relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, + method: resolvedMethod, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, + attemptsMade: attempt + 1, + retryCountUsed: attempt, + remainingRetries: resolvedMaxRetries - attempt, delayMs, status: error?.response?.status, + statusClass: getStatusClass(error?.response?.status), code: error?.code, - message: error?.message + message: error?.message, + elapsedMs: getElapsedMs(startedAtMs) }); await wait(delayMs); diff --git a/pages/api/middleware/relayPolicyPresets.js b/pages/api/middleware/relayPolicyPresets.js new file mode 100644 index 00000000..1a207082 --- /dev/null +++ b/pages/api/middleware/relayPolicyPresets.js @@ -0,0 +1,31 @@ +export const RELAY_POLICY_STRICT_LOGIN = { + method: "GET", + timeoutMs: 5000, + maxRetries: 0, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 +}; + +export const RELAY_POLICY_LOOKUP = { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 +}; + +export const RELAY_POLICY_BOUNDED_READ = { + method: "GET", + timeoutMs: 8000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 800 +}; + +export const RELAY_POLICY_SEARCH_PAGED = { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 +}; diff --git a/tests/phase21/_shared.cjs b/tests/phase21/_shared.cjs index 118144c0..811af4e8 100644 --- a/tests/phase21/_shared.cjs +++ b/tests/phase21/_shared.cjs @@ -55,6 +55,34 @@ const loadModule = (relativePath, injected = {}) => { error: () => {} }, relayGet: defaultRelayGet, + RELAY_POLICY_STRICT_LOGIN: { + method: "GET", + timeoutMs: 5000, + maxRetries: 0, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 + }, + RELAY_POLICY_LOOKUP: { + method: "GET", + timeoutMs: 6000, + maxRetries: 1, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500 + }, + RELAY_POLICY_BOUNDED_READ: { + method: "GET", + timeoutMs: 8000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 800 + }, + RELAY_POLICY_SEARCH_PAGED: { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }, ...injected }; diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 23dd939d..90a48c71 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -54,6 +54,32 @@ test("getaccounts catch path returns ACCOUNTS_FETCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "ACCOUNTS_FETCH_FAILED"); }); +test("getaccounts passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getaccounts_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + 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("getemailaccountcheck returns EMAIL_ADDRESS_REQUIRED when emailAddress missing", async () => { const mod = loadModule("pages/api/endpoint/getemailaccountcheck_api.js", { respondError: respondErrorMock, @@ -270,6 +296,33 @@ test("getlogin catch path returns LOGIN_FETCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "LOGIN_FETCH_FAILED"); }); +test("getlogin passes strict relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getlogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + }, + azureHeadersPaged: () => ({}) + }); + + const req = { query: { emailAddress: "user@test.local", pwd: "abc123" } }; + 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 returns HASH_REQUIRED when hash missing", async () => { const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { respondError: respondErrorMock, @@ -336,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, @@ -448,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, @@ -1612,6 +1725,32 @@ test("getmycases catch path returns MY_CASES_FETCH_FAILED", async () => { assert.strictEqual(res.state.jsonBody.error.code, "MY_CASES_FETCH_FAILED"); }); +test("getmycases passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getmycases_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { loggedInUserId: "c1" } }; + 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("getmyrepresentations returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => { const mod = loadModule("pages/api/endpoint/getmyrepresentations_api.js", { respondError: respondErrorMock, @@ -1663,6 +1802,32 @@ test("getmyrepresentations catch path returns MY_REPRESENTATIONS_FETCH_FAILED", ); }); +test("getmyrepresentations passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getmyrepresentations_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { loggedInUserId: "c1" } }; + 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("getwatchedcases returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => { const mod = loadModule("pages/api/endpoint/getwatchedcases_api.js", { respondError: respondErrorMock, @@ -1714,6 +1879,32 @@ test("getwatchedcases catch path returns WATCHED_CASES_FETCH_FAILED", async () = ); }); +test("getwatchedcases passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getwatchedcases_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { loggedInUserId: "c1" } }; + 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("getawaitingsubmission returns LOGGED_IN_USER_ID_REQUIRED when loggedInUserId missing", async () => { const mod = loadModule("pages/api/endpoint/getawaitingsubmission_api.js", { respondError: respondErrorMock, @@ -1765,6 +1956,32 @@ test("getawaitingsubmission catch path returns AWAITING_SUBMISSION_FETCH_FAILED" ); }); +test("getawaitingsubmission passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getawaitingsubmission_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { loggedInUserId: "c1" } }; + 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("getbasicsearchdetails returns APPEAL_TYPE_NAME_REQUIRED when appealTypeName missing", async () => { const mod = loadModule("pages/api/endpoint/getbasicsearchdetails_api.js", { respondError: respondErrorMock, @@ -1976,6 +2193,41 @@ test("getbasicsearchpaged catch path returns BASIC_SEARCH_PAGED_FETCH_FAILED", a ); }); +test("getbasicsearchpaged passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getbasicsearchpaged_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + }, + azureHeadersPagedCustom: () => ({}), + _: { has: () => false } + }); + + const req = { + query: { + searchString: "cas", + orderby: "createdon", + fieldSort: "asc", + showNumberOfRecords: "10" + } + }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { + method: "GET", + timeoutMs: 9000, + maxRetries: 2, + retryBaseDelayMs: 150, + retryMaxDelayMs: 1000 + }); +}); + test("getbasicsearch_by_lparref returns LPA_REF_REQUIRED when lpaRef missing", async () => { const mod = loadModule( "pages/api/endpoint/getbasicsearch_by_lparref_api.js", @@ -2322,6 +2574,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", @@ -2383,6 +2667,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, @@ -3394,6 +3713,32 @@ test("getrepresentations catch path returns REPRESENTATIONS_FETCH_FAILED", async ); }); +test("getrepresentations passes relayPolicy overrides to relayGet", async () => { + let capturedRelayPolicy = null; + + const mod = loadModule("pages/api/endpoint/getrepresentations_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async ({ relayPolicy, res }) => { + capturedRelayPolicy = relayPolicy; + return respondSuccessMock(res, { value: [] }); + } + }); + + const req = { query: { incidentID: "i1" } }; + 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("getdnscoords catch path returns DNS_COORDS_FETCH_FAILED", async () => { const mod = loadModule("pages/api/endpoint/getdnscoords_api.js", { respondError: respondErrorMock, diff --git a/tests/phase21/relay-forwarding-hardening.test.cjs b/tests/phase21/relay-forwarding-hardening.test.cjs index fd238806..975ec2d4 100644 --- a/tests/phase21/relay-forwarding-hardening.test.cjs +++ b/tests/phase21/relay-forwarding-hardening.test.cjs @@ -43,6 +43,7 @@ const test = (name, fn) => tests.push({ name, fn }); test("forwardGetData retries retryable HTTP status and then succeeds", async () => { let callCount = 0; + const infoLogs = []; const mod = loadRelayForwardingModule({ axios: { @@ -61,7 +62,13 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async () hashAPIPath: () => "&hash=abc", azureHeaders: () => ({ headers: { Authorization: "Bearer token" } }), redactSensitive: (value) => value, - consoleLogger: () => {} + consoleLogger: () => {}, + console: { + log: () => {}, + info: (...args) => infoLogs.push(args), + warn: () => {}, + error: () => {} + } }); const result = await mod.forwardGetData({ @@ -74,10 +81,39 @@ test("forwardGetData retries retryable HTTP status and then succeeds", async () assert.strictEqual(callCount, 2); assert.deepStrictEqual(result.data, { ok: true }); assert.strictEqual(result.accessToken, "token"); + + const eventNames = infoLogs.map((entry) => entry[0]); + assert.ok(eventNames.includes("relay_request_started")); + assert.ok(eventNames.includes("relay_request_retrying")); + assert.ok(eventNames.includes("relay_request_succeeded")); + + const retryEvent = infoLogs.find( + (entry) => entry[0] === "relay_request_retrying" + ); + assert.ok(retryEvent); + + const retryPayload = retryEvent[1]; + assert.strictEqual(retryPayload.statusClass, "5xx"); + assert.strictEqual(retryPayload.attemptsMade, 1); + assert.strictEqual(retryPayload.retryCountUsed, 0); + assert.strictEqual(retryPayload.remainingRetries, 1); + assert.ok(typeof retryPayload.relayRequestId === "string"); + + const successEvent = infoLogs.find( + (entry) => entry[0] === "relay_request_succeeded" + ); + assert.ok(successEvent); + + const successPayload = successEvent[1]; + assert.strictEqual(successPayload.statusClass, "none"); + assert.strictEqual(successPayload.attemptsMade, 2); + assert.strictEqual(successPayload.retryCountUsed, 1); + assert.ok(typeof successPayload.elapsedMs === "number"); }); test("forwardGetData does not retry non-retryable HTTP status", async () => { let callCount = 0; + const infoLogs = []; const mod = loadRelayForwardingModule({ axios: { @@ -92,7 +128,13 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => { hashAPIPath: () => "&hash=abc", azureHeaders: () => ({ headers: {} }), redactSensitive: (value) => value, - consoleLogger: () => {} + consoleLogger: () => {}, + console: { + log: () => {}, + info: (...args) => infoLogs.push(args), + warn: () => {}, + error: () => {} + } }); let thrown = null; @@ -109,6 +151,17 @@ test("forwardGetData does not retry non-retryable HTTP status", async () => { assert.ok(thrown); assert.strictEqual(callCount, 1); + + const failedEvent = infoLogs.find( + (entry) => entry[0] === "relay_request_failed" + ); + assert.ok(failedEvent); + + const failedPayload = failedEvent[1]; + assert.strictEqual(failedPayload.statusClass, "4xx"); + assert.strictEqual(failedPayload.attemptsMade, 1); + assert.strictEqual(failedPayload.retryCountUsed, 0); + assert.ok(typeof failedPayload.elapsedMs === "number"); }); test("forwardGetData does not retry unauthorized status", async () => { @@ -258,6 +311,210 @@ test("forwardGetData applies timeout and appendHash=false behavior", async () => assert.strictEqual(capturedOptions.headers.Accept, "application/json"); }); +test("forwardGetData telemetry request id is stable across lifecycle events", async () => { + const infoLogs = []; + let callCount = 0; + + const mod = loadRelayForwardingModule({ + axios: { + get: async () => { + callCount += 1; + if (callCount === 1) { + const error = new Error("service unavailable"); + error.response = { status: 503 }; + throw error; + } + + return { data: { ok: true }, status: 200 }; + } + }, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=abc", + azureHeaders: () => ({ headers: {} }), + redactSensitive: (value) => value, + consoleLogger: () => {}, + console: { + log: () => {}, + info: (...args) => infoLogs.push(args), + warn: () => {}, + error: () => {} + } + }); + + await mod.forwardGetData({ + queryUrl: "incidents?$top=1", + maxRetries: 1, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 + }); + + const lifecycleEvents = infoLogs + .filter((entry) => + [ + "relay_request_started", + "relay_request_retrying", + "relay_request_succeeded" + ].includes(entry[0]) + ) + .map((entry) => entry[1]); + + assert.strictEqual(lifecycleEvents.length, 3); + + const relayRequestIds = lifecycleEvents.map( + (event) => event.relayRequestId + ); + assert.ok( + relayRequestIds.every((id) => typeof id === "string" && id.length > 0) + ); + assert.strictEqual(new Set(relayRequestIds).size, 1); +}); + +test("forwardGetData relayPolicy override controls retry behavior", async () => { + let callCount = 0; + const infoLogs = []; + + const mod = loadRelayForwardingModule({ + axios: { + get: async () => { + callCount += 1; + if (callCount < 3) { + const error = new Error("temporary outage"); + error.response = { status: 503 }; + throw error; + } + + return { data: { ok: true }, status: 200 }; + } + }, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=abc", + azureHeaders: () => ({ headers: {} }), + redactSensitive: (value) => value, + consoleLogger: () => {}, + console: { + log: () => {}, + info: (...args) => infoLogs.push(args), + warn: () => {}, + error: () => {} + } + }); + + const result = await mod.forwardGetData({ + queryUrl: "incidents?$top=1", + maxRetries: 0, + relayPolicy: { + maxRetries: 2, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0, + method: "GET" + } + }); + + assert.strictEqual(callCount, 3); + assert.deepStrictEqual(result.data, { ok: true }); + + const startEvent = infoLogs.find( + (entry) => entry[0] === "relay_request_started" + ); + assert.ok(startEvent); + assert.strictEqual(startEvent[1].maxRetries, 2); + assert.strictEqual(startEvent[1].method, "GET"); +}); + +test("forwardGetData blocks retries for non-idempotent methods by default", async () => { + let callCount = 0; + const infoLogs = []; + + const mod = loadRelayForwardingModule({ + axios: { + get: async () => { + callCount += 1; + const error = new Error("temporary outage"); + error.response = { status: 503 }; + throw error; + } + }, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=abc", + azureHeaders: () => ({ headers: {} }), + redactSensitive: (value) => value, + consoleLogger: () => {}, + console: { + log: () => {}, + info: (...args) => infoLogs.push(args), + warn: () => {}, + error: () => {} + } + }); + + let thrown = null; + try { + await mod.forwardGetData({ + queryUrl: "incidents?$top=1", + relayPolicy: { + method: "POST", + maxRetries: 3, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0 + } + }); + } catch (error) { + thrown = error; + } + + assert.ok(thrown); + assert.strictEqual(callCount, 1); + + const retryEvents = infoLogs.filter( + (entry) => entry[0] === "relay_request_retrying" + ); + assert.strictEqual(retryEvents.length, 0); + + const failedEvent = infoLogs.find( + (entry) => entry[0] === "relay_request_failed" + ); + assert.ok(failedEvent); + assert.strictEqual(failedEvent[1].method, "POST"); +}); + +test("forwardGetData can allow retries for non-idempotent methods when explicitly enabled", async () => { + let callCount = 0; + + const mod = loadRelayForwardingModule({ + axios: { + get: async () => { + callCount += 1; + if (callCount === 1) { + const error = new Error("temporary outage"); + error.response = { status: 503 }; + throw error; + } + + return { data: { ok: true }, status: 200 }; + } + }, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=abc", + azureHeaders: () => ({ headers: {} }), + redactSensitive: (value) => value, + consoleLogger: () => {} + }); + + const result = await mod.forwardGetData({ + queryUrl: "incidents?$top=1", + relayPolicy: { + method: "PATCH", + maxRetries: 1, + retryBaseDelayMs: 0, + retryMaxDelayMs: 0, + allowNonIdempotentRetries: true + } + }); + + assert.strictEqual(callCount, 2); + assert.deepStrictEqual(result.data, { ok: true }); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) {