From 9db1149f2cd2734e77485e81ce9e243eee6b489d Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Thu, 24 Sep 2026 11:51:56 +0100 Subject: [PATCH 1/8] #FCT2-22132 - roll back OS london multi-targeting and make new oapps domains recognised as outsystems --- README.md | 5 +- configuration/config.prod.json | 24 +-- configuration/config.test.json | 27 ++-- configuration/config.uat.json | 27 ++-- .../proxy/config/main/global-components.conf | 2 +- infra/proxy/config/main/global-components.ts | 24 ++- .../main/tests/global-components.unit.test.ts | 26 ++++ .../src/global-script.ts | 13 +- .../is-outsystems-app.spec.ts | 6 + .../application-flags/is-outsystems-app.ts | 16 +- .../initialise-request-observation-shim.ts | 6 +- .../cps-global-configuration/src/Config.ts | 4 + .../cps-global-configuration/src/Preview.ts | 6 - .../src/apply-region-override.test.ts | 130 ----------------- .../src/apply-region-override.ts | 69 --------- .../src/csp/derive-check-targets.spec.ts | 39 +++-- .../src/csp/derive-check-targets.ts | 56 ++----- .../src/csp/render-check-report.spec.ts | 3 +- .../src/csp/render-check-report.ts | 10 +- .../cps-global-configuration/src/index.ts | 6 +- .../src/is-outsystems-host.test.ts | 39 +++++ .../src/is-outsystems-host.ts | 17 +++ .../src/outsystems-host-consistency.spec.ts | 56 +++++++ .../src/auth-handover.spec.ts | 138 ++---------------- .../cps-global-handover/src/auth-handover.ts | 95 ++---------- .../src/handle-os-token-return.test.ts | 10 +- .../src/handle-os-token-return.ts | 16 +- packages/cps-global-preview/src/App.tsx | 59 -------- 28 files changed, 297 insertions(+), 632 deletions(-) delete mode 100644 packages/cps-global-configuration/src/apply-region-override.test.ts delete mode 100644 packages/cps-global-configuration/src/apply-region-override.ts create mode 100644 packages/cps-global-configuration/src/is-outsystems-host.test.ts create mode 100644 packages/cps-global-configuration/src/is-outsystems-host.ts create mode 100644 packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts diff --git a/README.md b/README.md index 3dace7b24..65d7b10a7 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,8 @@ current state has a plain URL to share: https://.z33.web.core.windows.net/csp-check/ ``` -Running it locally probes every OutSystems host the configs reference — including the -`cpslon*` London hosts — and reports which required origins are missing, which -are granted only under a narrower path than we need, which are granted but no +Running it locally probes every OutSystems host the configs reference and reports +which required origins are missing, which are granted only under a narrower path than we need, which are granted but no longer used, and whether each tenant's uploaded `auth-handover.html` still matches this repository. Output lands in `generated/csp-check/` as `index.html` plus `report.md`, in the GitHub job summary, and — for scheduled runs — at the URL above. Latest result diff --git a/configuration/config.prod.json b/configuration/config.prod.json index 419dfc134..065ceb739 100644 --- a/configuration/config.prod.json +++ b/configuration/config.prod.json @@ -59,54 +59,54 @@ "cmsAuthFromStorageKey": "$OS_Users$Casework_Blocks$ClientVars$JSONString", "contexts": [ { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/casework(_blocks)?/home", + "path": "https://cps\\.outsystemsenterprise\\.com/casework(_blocks)?/home", "contextIds": "home", "showNotification": true }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", + "path": "https://cps\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", "contextIds": "unknown-home" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", + "path": "https://cps\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", "contextIds": "case review" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", + "path": "https://cps\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", "contextIds": "redirect", "preventADAndDataCalls": true }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/CaseReview/", + "path": "https://cps\\.outsystemsenterprise\\.com/CaseReview/", "contextIds": "cases" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", "contextIds": "case materials" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", "contextIds": "case details" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", "contextIds": "cases" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", "contextIds": "tasks" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", "contextIds": "redirect", "preventADAndDataCalls": true }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", "contextIds": "cases" }, { - "path": "https://(?:cps|cpslon)\\.outsystemsenterprise\\.com/WorkManagementApp", + "path": "https://cps\\.outsystemsenterprise\\.com/WorkManagementApp", "contextIds": "unknown-wma" } ] diff --git a/configuration/config.test.json b/configuration/config.test.json index b0ea08537..84e7e5dfb 100644 --- a/configuration/config.test.json +++ b/configuration/config.test.json @@ -34,6 +34,7 @@ "SHOW_HEADER_REBRAND": "cps", "OS_HANDOVER_URL": "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js", "OS_ENTRA_ID_STORAGE_KEY": "$OS_Users$Casework_Blocks$ClientVars$EntraID", + "OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN": true, "RECENT_CASES_NAVIGATE_URL": "https://cps-tst.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}", "RECENT_CASES_LIST_LENGTH": 5, "FEATURE_FLAG_MENU_USERS": { @@ -68,19 +69,19 @@ "cmsAuthFromStorageKey": "$OS_Users$Casework_Blocks$ClientVars$JSONString", "contexts": [ { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/casework(_blocks)?/home", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/casework(_blocks)?/home", "contextIds": "home", "showNotification": true }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", "contextIds": "unknown-home" }, { "caseLockingAppName": "Case Review App", "contexts": [ { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", "contextIds": "case review", "caseLockingRegion": { "code": "case_review" }, "hostAppEventTargets": [ @@ -91,53 +92,53 @@ ] }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", "contextIds": "redirect", "preventPageViewAnalytics": true }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/CaseReview/", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/CaseReview/", "contextIds": "cases" }, { "caseLockingAppName": "Work Management App", "contexts": [ { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", "contextIds": "case materials", "caseLockingRegion": { "code": "case" } }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", "contextIds": "case details", "caseLockingRegion": { "code": "victim_witness", "subject": "{witnessId}" } }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", "contextIds": "case details", "caseLockingRegion": { "code": "case" } } ] }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", "contextIds": "cases" }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", "contextIds": "tasks" }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", "contextIds": "redirect", "preventPageViewAnalytics": true }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", "contextIds": "cases" }, { - "path": "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/WorkManagementApp", + "path": "https://cps-tst\\.outsystemsenterprise\\.com/WorkManagementApp", "contextIds": "unknown-wma" } ] diff --git a/configuration/config.uat.json b/configuration/config.uat.json index ef56786d8..daadb2d68 100644 --- a/configuration/config.uat.json +++ b/configuration/config.uat.json @@ -34,6 +34,7 @@ "SHOW_HEADER_REBRAND": "cps", "OS_HANDOVER_URL": "https://cps-tst1.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https://polaris-uat-notprod.cps.gov.uk/global-components/uat/auth-handover.js", "OS_ENTRA_ID_STORAGE_KEY": "$OS_Users$Casework_Blocks$ClientVars$EntraID", + "OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN": true, "RECENT_CASES_NAVIGATE_URL": "https://cps-tst1.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}", "RECENT_CASES_LIST_LENGTH": 5, "FEATURE_FLAG_MENU_USERS": { @@ -68,19 +69,19 @@ "cmsAuthFromStorageKey": "$OS_Users$Casework_Blocks$ClientVars$JSONString", "contexts": [ { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/casework(_blocks)?/home", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/casework(_blocks)?/home", "contextIds": "home", "showNotification": true }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", "contextIds": "unknown-home" }, { "caseLockingAppName": "Case Review App", "contexts": [ { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", "contextIds": "case review", "caseLockingRegion": { "code": "case_review" }, "hostAppEventTargets": [ @@ -91,53 +92,53 @@ ] }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", "contextIds": "redirect", "preventPageViewAnalytics": true }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/CaseReview/", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/CaseReview/", "contextIds": "cases" }, { "caseLockingAppName": "Work Management App", "contexts": [ { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", "contextIds": "case materials", "caseLockingRegion": { "code": "case" } }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", "contextIds": "case details", "caseLockingRegion": { "code": "victim_witness", "subject": "{witnessId}" } }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", "contextIds": "case details", "caseLockingRegion": { "code": "case" } } ] }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", "contextIds": "cases" }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", "contextIds": "tasks" }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", "contextIds": "redirect", "preventPageViewAnalytics": true }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", "contextIds": "cases" }, { - "path": "https://(?:cps-tst1|cpslon-tst1)\\.outsystemsenterprise\\.com/WorkManagementApp", + "path": "https://cps-tst1\\.outsystemsenterprise\\.com/WorkManagementApp", "contextIds": "unknown-wma" } ] diff --git a/infra/proxy/config/main/global-components.conf b/infra/proxy/config/main/global-components.conf index 7bbeef2c8..9c919ced2 100644 --- a/infra/proxy/config/main/global-components.conf +++ b/infra/proxy/config/main/global-components.conf @@ -134,7 +134,7 @@ location /global-components/navigate-cms { # Case Review redirect: accepts ?URN=xxx&CMSCaseId=yyy from UI, # constructs full auth handover chain, redirects through /auth-refresh-outbound. # Path segments encode the OS subdomain and blob env folder: -# /case-review-redirect/{osSubdomain}/{envFolder} +# /case-review-redirect/{osHost}/{envFolder} (osHost: OS subdomain, or a full host) # e.g. /case-review-redirect/cps-tst/test location /case-review-redirect/ { js_content gloco.handleCaseReviewRedirect; diff --git a/infra/proxy/config/main/global-components.ts b/infra/proxy/config/main/global-components.ts index 9cc0901c6..44fb1b6c0 100644 --- a/infra/proxy/config/main/global-components.ts +++ b/infra/proxy/config/main/global-components.ts @@ -325,25 +325,39 @@ function handleNavigateCms(r: NginxHTTPRequest): void { ); } +const CASE_REVIEW_OS_HOST_PATTERN = + /^[a-z0-9-]+(\.[a-z0-9-]+)*\.(cps\.gov\.uk|outsystemsenterprise\.com)$/i; + function handleCaseReviewRedirect(r: NginxHTTPRequest): void { const proto = r.headersIn["X-Forwarded-Proto"] || "https"; const host = r.headersIn["Host"] || ""; - // URI is /case-review-redirect/{osSubdomain}/{envFolder} + // URI is /case-review-redirect/{osHost}/{envFolder}, where osHost is either + // an outsystemsenterprise.com subdomain (the original form) or a full host, + // so OutSystems can move off outsystemsenterprise.com (e.g. to oapps-*.cps.gov.uk) // e.g. /case-review-redirect/cps-tst/test + // /case-review-redirect/oapps-qa-notprod.int.cps.gov.uk/test const parts = r.uri.split("/"); - const osSubdomain = parts[2] || ""; + const osHost = parts[2] || ""; const envFolder = parts[3] || ""; - if (!osSubdomain || !envFolder) { + if (!osHost || !envFolder) { r.return( 400, - "case-review: expected path /case-review-redirect/{osSubdomain}/{envFolder}", + "case-review: expected path /case-review-redirect/{osHost}/{envFolder}", ); return; } - const osDomain = `${osSubdomain}.outsystemsenterprise.com`; + // A full host is only accepted inside our own estate — this segment ends up + // as a redirect target, and must not make us an open redirector. + const isFullHost = osHost.indexOf(".") !== -1; + if (isFullHost && !CASE_REVIEW_OS_HOST_PATTERN.test(osHost)) { + r.return(400, "case-review: unexpected OS host " + osHost); + return; + } + + const osDomain = isFullHost ? osHost.toLowerCase() : `${osHost}.outsystemsenterprise.com`; const caseId = r.args["CMSCaseId"] || ""; const urn = r.args["URN"] || ""; diff --git a/infra/proxy/config/main/tests/global-components.unit.test.ts b/infra/proxy/config/main/tests/global-components.unit.test.ts index 0eaf72814..c643e97d4 100644 --- a/infra/proxy/config/main/tests/global-components.unit.test.ts +++ b/infra/proxy/config/main/tests/global-components.unit.test.ts @@ -579,6 +579,32 @@ async function runTests(): Promise { ) }) + await test("accepts a full OS host in place of a subdomain", async () => { + const r = createMockRequest({ + uri: "/case-review-redirect/oapps-qa-notprod.int.cps.gov.uk/test", + args: { CMSCaseId: "42", URN: "12AB3456789" }, + headersIn: { "X-Forwarded-Proto": "https", Host: "polaris-qa.cps.gov.uk" }, + }) + gloco.handleCaseReviewRedirect(r) + assertEqual(r.returnCode, 302, "Should return 302") + + const authHandoverUrl = decodeURIComponent(r.returnBody!.split("?r=")[1]) + assert( + authHandoverUrl.startsWith("https://oapps-qa-notprod.int.cps.gov.uk/Casework_Patterns/auth-handover.html"), + `Should use the full OS host, got: ${authHandoverUrl}` + ) + }) + + await test("returns 400 for a full host outside our estate", async () => { + const r = createMockRequest({ + uri: "/case-review-redirect/evil.example.com/test", + args: { CMSCaseId: "42", URN: "12AB3456789" }, + headersIn: { "X-Forwarded-Proto": "https", Host: "polaris-qa.cps.gov.uk" }, + }) + gloco.handleCaseReviewRedirect(r) + assertEqual(r.returnCode, 400, "Should return 400") + }) + await test("returns 400 when CMSCaseId is missing", async () => { const r = createMockRequest({ uri: "/case-review-redirect/cps-tst/test", diff --git a/packages/cps-global-components/src/global-script.ts b/packages/cps-global-components/src/global-script.ts index 8fb15b573..3c957cf50 100644 --- a/packages/cps-global-components/src/global-script.ts +++ b/packages/cps-global-components/src/global-script.ts @@ -1,4 +1,3 @@ -import { applyRegionOverride } from "cps-global-configuration"; import { initialiseAuth } from "./services/auth/initialise-auth"; import { initialiseStore } from "./store/store"; import { initialiseAnalytics } from "./services/analytics/initialise-analytics"; @@ -81,7 +80,7 @@ const initialise = async (window: Window & typeof globalThis) => { // Config no longer depends on preview (override-via-preview was removed in // FCT2-17451 drop 4) so it joins the parallel set. - const [{ handover, setNextHandover }, preview, settings, { authHint, setAuthHint }, { userDataHint, setUserDataHint }, cmsSessionHint, loadedConfig] = await Promise.all([ + const [{ handover, setNextHandover }, preview, settings, { authHint, setAuthHint }, { userDataHint, setUserDataHint }, cmsSessionHint, config] = await Promise.all([ initialiseHandover({ rootUrl, register }), initialisePreview({ rootUrl, register }), initialiseSettings({ rootUrl }), @@ -91,16 +90,6 @@ const initialise = async (window: Window & typeof globalThis) => { initialiseConfig({ rootUrl, flags, register }), ]); - // Region override (FCT2-20670). Preview and config resolve together above, - // so the rewrite lands before anything reads either. initialiseConfig has - // already registered the un-overridden config, hence the re-register — - // applyRegionOverride returns the same reference when there's no override, - // so the identity check keeps the no-op case free. - const config = applyRegionOverride(loadedConfig, preview); - if (config !== loadedConfig) { - register({ config }); - } - initialiseOutSystemsReconcileAuth({ window, flags, config }); const { initialiseCaseLockingForContext } = initialiseCaseLocking({ window, config, preview, register }); diff --git a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts index e5394822e..c9ae3d8cd 100644 --- a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts +++ b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts @@ -22,6 +22,12 @@ describe("isOutSystemsApp", () => { expect(isOutSystemsApp(createMockWindow("https://a.b.c.outsystemsenterprise.com"))).toBe(true); }); + it("should return true for the oapps proxies OutSystems is moving behind", () => { + expect(isOutSystemsApp(createMockWindow("https://oapps.cps.gov.uk/Casework/home"))).toBe(true); + expect(isOutSystemsApp(createMockWindow("https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/TaskList"))).toBe(true); + expect(isOutSystemsApp(createMockWindow("https://polaris-qa-notprod.cps.gov.uk/polaris-ui"))).toBe(false); + }); + it("should return false for non-http/https URLs", () => { expect(isOutSystemsApp(createMockWindow("ftp://app.outsystemsenterprise.com"))).toBe(false); expect(isOutSystemsApp(createMockWindow("file:///app.outsystemsenterprise.com"))).toBe(false); diff --git a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.ts b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.ts index aab887178..639956521 100644 --- a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.ts +++ b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.ts @@ -1,16 +1,8 @@ +import { isOutSystemsUrl } from "cps-global-configuration"; import { withLogging } from "../../logging/with-logging"; -export const isOutSystemsAppInternal = ({ location: { href } }: { location: { href: string } }) => { - try { - const url = href; - if (!url) { - return false; - } - const resolvedUrl = url.toLowerCase(); - return resolvedUrl.toLowerCase().startsWith("http") && new URL(resolvedUrl).hostname.endsWith(".outsystemsenterprise.com"); - } catch (err) { - return false; - } -}; +// Shared with the CSP tooling so both agree on what counts as OutSystems — +// including the oapps-*.cps.gov.uk proxies OutSystems is moving behind. +export const isOutSystemsAppInternal = ({ location: { href } }: { location: { href: string } }) => isOutSystemsUrl(href); export const isOutSystemsApp = withLogging("isOutSystemsApp", isOutSystemsAppInternal); diff --git a/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.ts b/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.ts index 7cb7221a2..b41cbeae2 100644 --- a/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.ts +++ b/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.ts @@ -6,8 +6,10 @@ import { TrackEvent } from "../analytics/analytics-event"; const { _log, _warn, _debug } = makeConsole("request-observation-shim"); -// Only activate on the OutSystems triage page. -const ACTIVATION_URL_REGEX = /^https:\/\/[^/]+\.outsystemsenterprise\.com\/WorkManagementApp\/Triage(\/|$|\?)/i; +// Only activate on the OutSystems triage page. Host-agnostic, so it survives +// OutSystems moving off outsystemsenterprise.com (onto the oapps proxies); no +// other app has a /WorkManagementApp/Triage path. +const ACTIVATION_URL_REGEX = /^https:\/\/[^/]+\/WorkManagementApp\/Triage(\/|$|\?)/i; // The OutSystems screenservice endpoints we capture submissions for. Activation // already restricts us to the Triage page, so an endsWith match on the distinctive diff --git a/packages/cps-global-configuration/src/Config.ts b/packages/cps-global-configuration/src/Config.ts index 403e4171c..60002dd0c 100644 --- a/packages/cps-global-configuration/src/Config.ts +++ b/packages/cps-global-configuration/src/Config.ts @@ -283,6 +283,10 @@ export const configBaseSchema = z.object({ // read it. Blank/absent turns the feature off — no write happens. Tactical // bridge (FCT2-21199); the objectId is the same one the AuthHint carries. OS_ENTRA_ID_STORAGE_KEY: z.string().optional(), + // FCT2-16735 temporary feature gate: on a fresh CMS token, the OS handover + // clears the user's stale tasklist filters. Was keyed off the OS hostname + // (cps-tst*, i.e. test and uat); a config flag survives OS changing domain. + OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN: z.boolean().optional(), FEATURE_FLAG_MENU_USERS: featureFlagUsersSchema.optional(), FEATURE_FLAG_USE_MSAL_FULL_REDIRECT_USERS: featureFlagUsersSchema.optional(), FEATURE_FLAG_CASE_LOCKING_USERS: featureFlagUsersSchema.optional(), diff --git a/packages/cps-global-configuration/src/Preview.ts b/packages/cps-global-configuration/src/Preview.ts index 18a56ced1..405d5871a 100644 --- a/packages/cps-global-configuration/src/Preview.ts +++ b/packages/cps-global-configuration/src/Preview.ts @@ -54,12 +54,6 @@ export const PreviewSchema = z.object({ caseLockingNotifications: z.boolean().optional(), caseLockingCountSelf: z.boolean().optional(), requestObservationShim: z.boolean().optional(), - - // OutSystems region override (FCT2-20670). Absent means no override, i.e. - // Dublin — the domain every config already hardcodes. "frontDoor" is - // reserved for the eventual front-door domain; until we know it, selecting it - // is disabled in the preview UI and it maps to no rewrite. - region: z.union([z.literal("london"), z.literal("frontDoor")]).optional(), }); export type Preview = z.infer; diff --git a/packages/cps-global-configuration/src/apply-region-override.test.ts b/packages/cps-global-configuration/src/apply-region-override.test.ts deleted file mode 100644 index f5b041107..000000000 --- a/packages/cps-global-configuration/src/apply-region-override.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - applyRegionOverride, - applyRegionToString, - getPreviewRegion, -} from "./apply-region-override"; -import { Config } from "./Config"; -import { Preview } from "./Preview"; -import { Result } from "./Result"; - -const london: Result = { found: true, result: { region: "london" } }; -const noOverride: Result = { found: true, result: {} }; -const frontDoor: Result = { - found: true, - result: { region: "frontDoor" }, -}; -const notFound: Result = { found: false, error: new Error("nope") }; - -const baseConfig = { - ENVIRONMENT: "test", - BANNER_TITLE_HREF: "https://cps-tst.outsystemsenterprise.com/Casework/home", - OS_HANDOVER_URL: - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js", - LINKS: [ - { - label: "Tasks", - href: "https://cps-tst.outsystemsenterprise.com/WorkManagementApp/TaskList", - activeContexts: "a", - visibleContexts: "a", - level: 1, - }, - ], - CONTEXTS: [ - { - path: "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/CaseReview", - contextIds: "a", - msalRedirectUrl: - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html", - }, - ], -} as unknown as Config; - -describe("getPreviewRegion", () => { - it("reads the region from a found preview", () => { - expect(getPreviewRegion(london)).toBe("london"); - }); - - it("is undefined when the preview is absent", () => { - expect(getPreviewRegion(notFound)).toBeUndefined(); - }); -}); - -describe("applyRegionToString", () => { - it.each([ - ["https://cps.outsystemsenterprise.com/x", "https://cpslon.outsystemsenterprise.com/x"], - ["https://cps-tst.outsystemsenterprise.com/x", "https://cpslon-tst.outsystemsenterprise.com/x"], - ["https://cps-tst1.outsystemsenterprise.com/x", "https://cpslon-tst1.outsystemsenterprise.com/x"], - ])("transposes %s", (input, expected) => { - expect(applyRegionToString(input, "london")).toBe(expected); - }); - - it("leaves polaris hosts alone even though they contain cps", () => { - const polaris = "https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js"; - expect(applyRegionToString(polaris, "london")).toBe(polaris); - }); - - it("is idempotent — an already-London host is untouched", () => { - const alreadyLondon = "https://cpslon-tst.outsystemsenterprise.com/x"; - expect(applyRegionToString(alreadyLondon, "london")).toBe(alreadyLondon); - }); - - it("does not rewrite for frontDoor, which has no host yet", () => { - const dublin = "https://cps-tst.outsystemsenterprise.com/x"; - expect(applyRegionToString(dublin, "frontDoor")).toBe(dublin); - }); - - it("does not rewrite when there is no override", () => { - const dublin = "https://cps-tst.outsystemsenterprise.com/x"; - expect(applyRegionToString(dublin, undefined)).toBe(dublin); - }); -}); - -describe("applyRegionOverride", () => { - it.each([ - ["no override", noOverride], - ["frontDoor", frontDoor], - ["absent preview", notFound], - ])("returns the identical reference for %s", (_label, preview) => { - expect(applyRegionOverride(baseConfig, preview)).toBe(baseConfig); - }); - - it("transposes link hrefs", () => { - const result = applyRegionOverride(baseConfig, london); - expect(result.LINKS[0].href).toBe( - "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/TaskList", - ); - }); - - it("transposes top-level urls including the banner and handover", () => { - const result = applyRegionOverride(baseConfig, london); - expect(result.BANNER_TITLE_HREF).toBe( - "https://cpslon-tst.outsystemsenterprise.com/Casework/home", - ); - expect(result.OS_HANDOVER_URL).toBe( - "https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js", - ); - }); - - it("transposes msalRedirectUrl", () => { - const result = applyRegionOverride(baseConfig, london); - expect(result.CONTEXTS[0].msalRedirectUrl).toBe( - "https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html", - ); - }); - - // Rewriting the path would collapse the alternation to cpslon|cpslon and stop - // the component loading on Dublin — where it has to load to redirect at all. - it("leaves CONTEXTS[].path alone so both hosts still match", () => { - const result = applyRegionOverride(baseConfig, london); - expect(result.CONTEXTS[0].path).toBe( - "https://(?:cps-tst|cpslon-tst)\\.outsystemsenterprise\\.com/CaseReview", - ); - }); - - it("does not mutate the input config", () => { - applyRegionOverride(baseConfig, london); - expect(baseConfig.LINKS[0].href).toBe( - "https://cps-tst.outsystemsenterprise.com/WorkManagementApp/TaskList", - ); - }); -}); diff --git a/packages/cps-global-configuration/src/apply-region-override.ts b/packages/cps-global-configuration/src/apply-region-override.ts deleted file mode 100644 index 3ad471b25..000000000 --- a/packages/cps-global-configuration/src/apply-region-override.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Config } from "./Config"; -import { Preview } from "./Preview"; -import { Result } from "./Result"; - -// Region override (FCT2-20670). The OutSystems host is the only part of the -// domain that varies by region — cps → cpslon, cps-tst → cpslon-tst, -// cps-tst1 → cpslon-tst1 — so one prefix rule covers every environment. -// -// Anchoring on `.outsystemsenterprise.com` is load-bearing: a bare cps → cpslon -// rewrite would also mangle the polaris URLs (polaris-qa-notprod.cps.gov.uk) -// that sit inside OS_HANDOVER_URL's `src=` param and inside msalRedirectUrl. -// -// The pattern only matches the plain form. No config value carries a -// percent-encoded OutSystems domain today (the encoded `src=` values point at -// polaris, which we deliberately leave alone) — if one ever does, it will need -// decoding before it reaches here. -const OS_HOST_PATTERN = - /https:\/\/cps(-[a-z0-9]+)?\.outsystemsenterprise\.com/g; - -const toLondon = (value: string): string => - value.replace( - OS_HOST_PATTERN, - (_match, suffix: string | undefined) => - `https://cpslon${suffix ?? ""}.outsystemsenterprise.com`, - ); - -export const getPreviewRegion = ( - preview: Result, -): Preview["region"] => (preview.found ? preview.result.region : undefined); - -// "frontDoor" is reserved — we don't know the domain yet, so it maps to no -// rewrite and the preview UI keeps the option disabled. -export const applyRegionToString = ( - value: string, - region: Preview["region"], -): string => (region === "london" ? toLondon(value) : value); - -// CONTEXTS[].path is deliberately left alone. Those regexes carry an -// alternation over both hosts, so rewriting one would collapse -// `cps-tst|cpslon-tst` into `cpslon-tst|cpslon-tst` and stop the component -// loading on Dublin — which is exactly where it has to load in order to send -// the user to London in the first place. -const rewriteNode = (node: unknown): unknown => { - if (typeof node === "string") { - return toLondon(node); - } - if (Array.isArray(node)) { - return node.map(rewriteNode); - } - if (node && typeof node === "object") { - return Object.fromEntries( - Object.entries(node).map(([key, value]) => [ - key, - key === "path" ? value : rewriteNode(value), - ]), - ); - } - return node; -}; - -// Returns the config unchanged (same reference) when there is no override, so -// callers can identity-check to decide whether anything needs re-registering. -export const applyRegionOverride = ( - config: Config, - preview: Result, -): Config => - getPreviewRegion(preview) === "london" - ? (rewriteNode(config) as Config) - : config; diff --git a/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts b/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts index eac4ff0c8..2c93f381c 100644 --- a/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts +++ b/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts @@ -28,33 +28,32 @@ describe("deriveCheckTargets", () => { it("collapses several links in one module to a single probe", () => { const wma = targets.filter(t => t.url.endsWith("/WorkManagementApp")); - expect(wma).toHaveLength(2); // dublin + london, not four + expect(wma).toHaveLength(1); }); it("ignores relative hrefs and non-OutSystems hosts", () => { - expect(targets.every(t => t.url.includes("outsystemsenterprise.com"))).toBe( - true, - ); + expect(targets.map(t => new URL(t.url).hostname)).toEqual([ + "cps-tst.outsystemsenterprise.com", + "cps-tst.outsystemsenterprise.com", + "cps-tst.outsystemsenterprise.com", + ]); }); it("includes the handover page as its own kind", () => { expect( targets.filter(t => t.kind === "auth-handover").map(t => t.url), - ).toContain(HANDOVER_URL); + ).toEqual([HANDOVER_URL]); }); - it("adds the London twin of every Dublin target", () => { - // The hand-listed version checked none of these. - expect(targets.filter(t => t.region === "london").map(t => t.url)).toContain( - "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp", - ); - }); + // The checker follows OutSystems onto the oapps proxies without change. + it("probes OutSystems on an oapps proxy host", () => { + const oapps = deriveCheckTargets("test", { + LINKS: [{ href: "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/TaskList" }], + } as never); - it("produces one London target for each Dublin one", () => { - const dublin = targets.filter(t => t.region === "dublin"); - const london = targets.filter(t => t.region === "london"); - - expect(london).toHaveLength(dublin.length); + expect(oapps.map(t => t.url)).toEqual([ + "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp", + ]); }); }); @@ -66,17 +65,13 @@ describe("against the committed configs", () => { it("covers every OutSystems host the configs reference", () => { const hosts = new Set(targets.map(t => new URL(t.url).hostname)); - // The four Dublin hosts plus their London twins. The previous hand-written - // list named three of these eight. + // One OutSystems host per environment. The previous hand-written list + // named three of these four. expect([...hosts].sort()).toEqual([ "cps-dev.outsystemsenterprise.com", "cps-tst.outsystemsenterprise.com", "cps-tst1.outsystemsenterprise.com", "cps.outsystemsenterprise.com", - "cpslon-dev.outsystemsenterprise.com", - "cpslon-tst.outsystemsenterprise.com", - "cpslon-tst1.outsystemsenterprise.com", - "cpslon.outsystemsenterprise.com", ]); }); diff --git a/packages/cps-global-configuration/src/csp/derive-check-targets.ts b/packages/cps-global-configuration/src/csp/derive-check-targets.ts index 0b135cdca..51e1b07d9 100644 --- a/packages/cps-global-configuration/src/csp/derive-check-targets.ts +++ b/packages/cps-global-configuration/src/csp/derive-check-targets.ts @@ -1,41 +1,29 @@ import type { Config } from "../Config"; -import { applyRegionToString } from "../apply-region-override"; +import { isOutSystemsUrl } from "../is-outsystems-host"; /** * Works out which URLs the live checker should probe. * * Derived rather than hand-listed, because the hand-listed version was wrong in * three ways at once: it named three subdomains when the configs reference - * four, it missed every London (`cpslon*`) host, and it pointed at - * `/Casework_blocks` after that module had been renamed. + * four, it missed hosts, and it pointed at `/Casework_blocks` after that module + * had been renamed. * * `LINKS[].href` is the source of truth for screens. It holds real absolute * URLs rather than the regexes in `CONTEXTS[].path`, and it cannot rot quietly - * — a wrong href breaks the menu in front of users. + * — a wrong href breaks the menu in front of users. Which of them are + * OutSystems is the shared isOutSystemsUrl check, so the checker follows + * OutSystems onto the oapps proxies too. */ export type CheckTarget = { environment: string; url: string; - // London hosts are the same app behind a regional front door; worth probing - // separately since their CSP is configured independently, but worth labelling - // so a report does not read as twice as many distinct apps. - region: "dublin" | "london"; kind: "screen" | "auth-handover"; }; type TargetConfig = Pick; -const OUTSYSTEMS_HOST_SUFFIX = ".outsystemsenterprise.com"; - -const isOutSystemsUrl = (value: string): boolean => { - try { - return new URL(value).hostname.endsWith(OUTSYSTEMS_HOST_SUFFIX); - } catch { - return false; - } -}; - // One probe per app module, not per link. Several links point into the same // module and OutSystems serves one policy per app, so probing each link would // multiply the report without adding information. @@ -64,40 +52,14 @@ export const deriveCheckTargets = ( ? new URL(config.OS_HANDOVER_URL).href : undefined; - const dublin: CheckTarget[] = [ + return [ ...[...screenRoots].map( - (url): CheckTarget => ({ - environment, - url, - region: "dublin", - kind: "screen", - }), + (url): CheckTarget => ({ environment, url, kind: "screen" }), ), ...(handoverUrl - ? [ - { - environment, - url: handoverUrl, - region: "dublin" as const, - kind: "auth-handover" as const, - }, - ] + ? [{ environment, url: handoverUrl, kind: "auth-handover" as const }] : []), ]; - - // Reuses the shipped cps -> cpslon transform rather than restating it, so the - // checker cannot disagree with what the component does at runtime. - const london = dublin - .map( - (target): CheckTarget => ({ - ...target, - url: applyRegionToString(target.url, "london"), - region: "london", - }), - ) - .filter((target, index) => target.url !== dublin[index]!.url); - - return [...dublin, ...london]; }; export const deriveAllCheckTargets = ( diff --git a/packages/cps-global-configuration/src/csp/render-check-report.spec.ts b/packages/cps-global-configuration/src/csp/render-check-report.spec.ts index 032785014..f8db4299f 100644 --- a/packages/cps-global-configuration/src/csp/render-check-report.spec.ts +++ b/packages/cps-global-configuration/src/csp/render-check-report.spec.ts @@ -11,7 +11,6 @@ import type { CspRequirement } from "./csp-requirements"; const target: CheckTarget = { environment: "test", url: "https://cps-tst.outsystemsenterprise.com/WorkManagementApp", - region: "dublin", kind: "screen", }; @@ -266,7 +265,7 @@ describe("the HTML page is self-contained", () => { }); describe("renderTldr", () => { - const target = (environment: string, url: string) => ({ environment, url, region: "dublin" as const, kind: "screen" as const }); + const target = (environment: string, url: string) => ({ environment, url, kind: "screen" as const }); const finding = (directive: string, value: string, verdict: "allowed" | "narrower" | "absent") => ({ requirement: { directive, value, reason: "because" } as never, verdict, diff --git a/packages/cps-global-configuration/src/csp/render-check-report.ts b/packages/cps-global-configuration/src/csp/render-check-report.ts index 252cdd468..e920a27e7 100644 --- a/packages/cps-global-configuration/src/csp/render-check-report.ts +++ b/packages/cps-global-configuration/src/csp/render-check-report.ts @@ -96,7 +96,7 @@ const summaryRow = (result: TargetResult): string => { .filter(Boolean) .join(", "); - return `| ${ICON[statusOf(result)]} | ${target.environment} | ${target.region} | \`${new URL(target.url).pathname}\` | ${new URL(target.url).hostname} | ${notes || "—"} |`; + return `| ${ICON[statusOf(result)]} | ${target.environment} | \`${new URL(target.url).pathname}\` | ${new URL(target.url).hostname} | ${notes || "—"} |`; }; const detailSection = (result: TargetResult): string[] => { @@ -239,8 +239,8 @@ export const renderMarkdownReport = ( "Requirements are derived from `configuration/config..json`; see", "`generated/csp/CSP-REQUIREMENTS.md` for what each origin is for.", "", - "| | Env | Region | Path | Host | Notes |", - "| --- | --- | --- | --- | --- | --- |", + "| | Env | Path | Host | Notes |", + "| --- | --- | --- | --- | --- |", ...results.map(summaryRow), "", ...results.flatMap(detailSection), @@ -296,7 +296,7 @@ export const renderHtmlReport = (

OutSystems CSP check

Checked ${escapeHtml(generatedAt)}. Requirements derived from the environment configs.

- + ${results .map(r => { const status = statusOf(r); @@ -311,7 +311,7 @@ ${results ] .filter(Boolean) .join("; "); - return ``; + return ``; }) .join("\n")}
EnvRegionHostPathNotes
EnvHostPathNotes
${ICON[status]}${escapeHtml(r.target.environment)}${escapeHtml(r.target.region)}${escapeHtml(url.hostname)}${escapeHtml(url.pathname)}${escapeHtml(notes || "—")}
${ICON[status]}${escapeHtml(r.target.environment)}${escapeHtml(url.hostname)}${escapeHtml(url.pathname)}${escapeHtml(notes || "—")}
diff --git a/packages/cps-global-configuration/src/index.ts b/packages/cps-global-configuration/src/index.ts index e509853f7..844625bd3 100644 --- a/packages/cps-global-configuration/src/index.ts +++ b/packages/cps-global-configuration/src/index.ts @@ -12,11 +12,7 @@ export { type SkipLinks, } from "./Config"; export { PreviewSchema, type Preview } from "./Preview"; -export { - applyRegionOverride, - applyRegionToString, - getPreviewRegion, -} from "./apply-region-override"; +export { isOutSystemsHostname, isOutSystemsUrl } from "./is-outsystems-host"; export { notificationSchema, notificationsFileSchema, diff --git a/packages/cps-global-configuration/src/is-outsystems-host.test.ts b/packages/cps-global-configuration/src/is-outsystems-host.test.ts new file mode 100644 index 000000000..04b391bf4 --- /dev/null +++ b/packages/cps-global-configuration/src/is-outsystems-host.test.ts @@ -0,0 +1,39 @@ +import { isOutSystemsHostname, isOutSystemsUrl } from "./is-outsystems-host"; + +describe("isOutSystemsHostname", () => { + it.each([ + "cps.outsystemsenterprise.com", + "cps-tst.outsystemsenterprise.com", + "CPS-TST1.outsystemsenterprise.com", + "oapps.cps.gov.uk", + "oapps-qa-notprod.int.cps.gov.uk", + "oapps-dev-notprod.int.cps.gov.uk", + ])("recognises %s", hostname => { + expect(isOutSystemsHostname(hostname)).toBe(true); + }); + + it.each([ + "polaris-qa-notprod.cps.gov.uk", + "polaris.cps.gov.uk", + // Only the proxy hosts themselves, not anything else under cps.gov.uk that + // happens to start with oapps. + "oapps-evil.cps.gov.uk", + "oapps.cps.gov.uk.example.com", + "outsystemsenterprise.com.example.com", + "notoutsystemsenterprise.com", + ])("rejects %s", hostname => { + expect(isOutSystemsHostname(hostname)).toBe(false); + }); +}); + +describe("isOutSystemsUrl", () => { + it.each([ + ["https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/TaskList", true], + ["https://cps-tst.outsystemsenterprise.com/Casework/home", true], + ["https://polaris-qa-notprod.cps.gov.uk/polaris-ui", false], + ["/cases/{caseId}", false], + ["not a url", false], + ])("isOutSystemsUrl(%s) is %s", (href, expected) => { + expect(isOutSystemsUrl(href)).toBe(expected); + }); +}); diff --git a/packages/cps-global-configuration/src/is-outsystems-host.ts b/packages/cps-global-configuration/src/is-outsystems-host.ts new file mode 100644 index 000000000..1e6d6fa1d --- /dev/null +++ b/packages/cps-global-configuration/src/is-outsystems-host.ts @@ -0,0 +1,17 @@ +// Which hosts are OutSystems. Knows both the outsystemsenterprise.com tenants +// and the CPS reverse proxies OutSystems is moving behind (oapps.cps.gov.uk in +// prod, oapps--notprod.int.cps.gov.uk elsewhere), so recognising OS needs +// no change when an environment moves across. It only classifies a host — which +// host an environment actually uses is whatever its config's URLs say. +const OUTSYSTEMS_HOSTNAME = /\.outsystemsenterprise\.com$|^oapps(-[a-z0-9]+-notprod\.int)?\.cps\.gov\.uk$/i; + +export const isOutSystemsHostname = (hostname: string): boolean => OUTSYSTEMS_HOSTNAME.test(hostname); + +export const isOutSystemsUrl = (href: string): boolean => { + try { + const url = new URL(href); + return url.protocol.startsWith("http") && isOutSystemsHostname(url.hostname); + } catch { + return false; + } +}; diff --git a/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts b/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts new file mode 100644 index 000000000..a5dd74b0f --- /dev/null +++ b/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isOutSystemsHostname } from "./is-outsystems-host"; + +/** + * One OutSystems host per environment, written out literally in each config. + * Moving an environment to another OS host (e.g. onto the oapps proxies) is a + * find-and-replace across its config; this is what catches a URL or context + * path that got missed — or a second host sneaking back in. + */ + +const CONFIG_DIR = join(__dirname, "..", "..", "..", "configuration"); +const ENVIRONMENTS = ["dev", "test", "uat", "prod"]; + +type Found = { urlHosts: string[]; pathHosts: string[] }; + +const collect = (node: unknown, found: Found, key?: string): Found => { + if (typeof node === "string") { + if (key === "path") { + // A context path is a regex; its host is everything between the scheme + // and the first "/", with escaped dots read as dots. + const host = node.match(/^https?:\/\/([^/]+)/)?.[1]?.replace(/\\\./g, "."); + if (host && /outsystemsenterprise|oapps/i.test(host)) { + found.pathHosts.push(host.toLowerCase()); + } + } else { + for (const [, host] of node.matchAll(/https?:\/\/([^/?#"\s]+)/g)) { + if (isOutSystemsHostname(host!)) { + found.urlHosts.push(host!.toLowerCase()); + } + } + } + } else if (Array.isArray(node)) { + node.forEach(item => collect(item, found)); + } else if (node && typeof node === "object") { + Object.entries(node).forEach(([childKey, value]) => collect(value, found, childKey)); + } + return found; +}; + +describe.each(ENVIRONMENTS)("config.%s.json", env => { + const { urlHosts, pathHosts } = collect( + JSON.parse(readFileSync(join(CONFIG_DIR, `config.${env}.json`), "utf8")), + { urlHosts: [], pathHosts: [] }, + ); + const [host] = urlHosts; + + it("names exactly one OutSystems host across its URLs", () => { + expect(new Set(urlHosts).size).toBe(1); + }); + + it("matches that same host, and only that host, in every OutSystems context path", () => { + expect(pathHosts.length).toBeGreaterThan(0); + expect(new Set(pathHosts)).toEqual(new Set([host])); + }); +}); diff --git a/packages/cps-global-handover/src/auth-handover.spec.ts b/packages/cps-global-handover/src/auth-handover.spec.ts index f19e18738..e7107eda6 100644 --- a/packages/cps-global-handover/src/auth-handover.spec.ts +++ b/packages/cps-global-handover/src/auth-handover.spec.ts @@ -23,11 +23,6 @@ jest.mock("cps-global-configuration", () => { return { ...actual, fetchConfig: jest.fn(), - // Real implementation by default (restored in beforeEach). The redirect - // allowlist is unreachable through the real transposition — that is the - // point of it — so the only way to prove the guard bites is to make this - // return a host it should never produce. - applyRegionToString: jest.fn(), }; }); @@ -36,7 +31,7 @@ jest.mock("cps-global-configuration", () => { const mockFetch = jest.fn<(input: unknown, init?: RequestInit) => Promise>(); global.fetch = mockFetch as unknown as typeof fetch; -import { applyRegionToString, fetchConfig } from "cps-global-configuration"; +import { fetchConfig } from "cps-global-configuration"; import { handleMsalEnsureAd, handleMsalLogin, @@ -50,12 +45,6 @@ import { dispatchHandover } from "./auth-handover"; const mockFetchConfig = fetchConfig as jest.MockedFunction; -const mockApplyRegionToString = applyRegionToString as jest.MockedFunction< - typeof applyRegionToString ->; -const realApplyRegionToString = jest.requireActual< - typeof import("cps-global-configuration") ->("cps-global-configuration").applyRegionToString; const mockHandleMsalLogin = handleMsalLogin as jest.MockedFunction< typeof handleMsalLogin @@ -122,9 +111,6 @@ describe("dispatchHandover", () => { jest.clearAllMocks(); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); - // clearAllMocks doesn't drop implementations, so restore the real - // transposition explicitly — otherwise the allowlist test below leaks. - mockApplyRegionToString.mockImplementation(realApplyRegionToString); // Default outcomes for sub-modules. Per-test setup overrides as needed. mockHandleMsalTermination.mockResolvedValue({ outcome: "handled" }); mockHandleOsCookieReturn.mockReturnValue({ @@ -192,6 +178,20 @@ describe("dispatchHandover", () => { }); describe("os-token-return stage", () => { + test("passes the tasklist-reset gate through from config", async () => { + withConfigOverrides({ OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN: true }); + const win = makeWindow( + "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=os-token-return&cc=abc&cms-modern-token=tok", + ); + + await dispatchHandover(win, scriptUrl); + + expect(mockHandleOsTokenReturn).toHaveBeenCalledWith(win, { + cmsAuthStorageKeys, + resetTasklistFiltersOnFreshToken: true, + }); + }); + test("navigates straight to stored target — OS handover is decoupled from AD (kill switch off)", async () => { mockHandleOsTokenReturn.mockResolvedValue({ kind: "ready", @@ -205,6 +205,7 @@ describe("dispatchHandover", () => { expect(mockHandleOsTokenReturn).toHaveBeenCalledWith(win, { cmsAuthStorageKeys, + resetTasklistFiltersOnFreshToken: false, }); expect(mockHandleMsalEnsureAd).not.toHaveBeenCalled(); expect(win.location.replace).toHaveBeenCalledWith( @@ -792,113 +793,6 @@ describe("dispatchHandover", () => { }); }); - describe("region override redirect (FCT2-20670)", () => { - const withPreview = (preview: unknown) => - mockFetch.mockImplementation(async (input: unknown) => - String(input).includes("/state/preview") - ? ({ ok: true, json: async () => preview } as never) - : ({ ok: false, status: 404, statusText: "Not Found" } as never), - ); - - test("moves a Dublin user to London, transposing the host and every OS param", async () => { - withPreview({ region: "london" }); - const win = makeWindow( - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=ensure-ad&returnTo=https%3A%2F%2Fcps-tst.outsystemsenterprise.com%2Fcasework_blocks%2Fhome", - ); - - await dispatchHandover(win, scriptUrl); - - const target = new URL( - (win.location.replace as jest.Mock).mock.calls[0][0] as string, - ); - expect(target.hostname).toBe("cpslon-tst.outsystemsenterprise.com"); - // returnTo has to come across too — resolveReturnTo demands same-origin, - // so a Dublin returnTo would be rejected once we land on London. - expect(target.searchParams.get("returnTo")).toBe( - "https://cpslon-tst.outsystemsenterprise.com/casework_blocks/home", - ); - expect(target.searchParams.get("stage")).toBe("ensure-ad"); - }); - - test("redirects before the stage is dispatched — handover storage doesn't cross origins", async () => { - withPreview({ region: "london" }); - const win = makeWindow( - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=os-cookie-return&cc=abc", - ); - - await dispatchHandover(win, scriptUrl); - - expect(mockHandleOsCookieReturn).not.toHaveBeenCalled(); - expect(win.location.replace).toHaveBeenCalledTimes(1); - }); - - test.each([ - ["already on London", "https://cpslon-tst.outsystemsenterprise.com"], - ["the polaris-served variant, not an OS host", "https://polaris.example"], - ])("does not redirect when %s", async (_label, origin) => { - withPreview({ region: "london" }); - const win = makeWindow( - `${origin}/Casework_Patterns/auth-handover.html?stage=ensure-ad`, - ); - - await dispatchHandover(win, scriptUrl); - - expect(mockHandleMsalEnsureAd).toHaveBeenCalledTimes(1); - }); - - test.each([ - ["there is no region override", {}], - ["the region is frontDoor, which has no host yet", { region: "frontDoor" }], - ])("does not redirect when %s", async (_label, preview) => { - withPreview(preview); - const win = makeWindow( - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=ensure-ad", - ); - - await dispatchHandover(win, scriptUrl); - - expect(mockHandleMsalEnsureAd).toHaveBeenCalledTimes(1); - }); - - // The allowlist exists for the day a region's host comes from config (the - // front-door option) rather than from the origin we're already on. Forcing - // a bad transposition is the only way to reach it. - test("fails closed on an off-domain host, dispatching normally instead", async () => { - withPreview({ region: "london" }); - mockApplyRegionToString.mockImplementation(() => "https://evil.example"); - const win = makeWindow( - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=ensure-ad&returnTo=https%3A%2F%2Fcps-tst.outsystemsenterprise.com%2Fhome", - ); - - await dispatchHandover(win, scriptUrl); - - expect(win.location.replace).not.toHaveBeenCalledWith( - expect.stringContaining("evil.example"), - ); - // Failing closed means the user still gets a working handover. - expect(mockHandleMsalEnsureAd).toHaveBeenCalledTimes(1); - }); - - // Setting .host never changes the scheme, so target.protocol is always the - // page's own — the guard is really "don't bounce an http page onward". - test("fails closed when the page itself is not https", async () => { - withPreview({ region: "london" }); - mockApplyRegionToString.mockImplementation( - () => "https://cpslon-tst.outsystemsenterprise.com", - ); - const win = makeWindow( - "http://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?stage=ensure-ad", - ); - - await dispatchHandover(win, scriptUrl); - - expect(win.location.replace).not.toHaveBeenCalledWith( - expect.stringContaining("cpslon-tst"), - ); - expect(mockHandleMsalEnsureAd).toHaveBeenCalledTimes(1); - }); - }); - describe("unknown / missing stage", () => { test("no-ops when stage is absent (direct access)", async () => { const win = makeWindow( diff --git a/packages/cps-global-handover/src/auth-handover.ts b/packages/cps-global-handover/src/auth-handover.ts index e5c202162..6d62f9bb0 100644 --- a/packages/cps-global-handover/src/auth-handover.ts +++ b/packages/cps-global-handover/src/auth-handover.ts @@ -12,14 +12,12 @@ */ import { - applyRegionToString, AuthHint, AuthHintSchema, Config, FEATURE_FLAGS, fetchConfig, fetchState, - getPreviewRegion, HANDOVER_PARAM_KEYS, HANDOVER_STAGES, Me, @@ -41,11 +39,9 @@ import { // Kill switch for the preemptive AD check on the OS handover branches. // When false, OS_COOKIE_RETURN and OS_TOKEN_RETURN navigate straight to their -// target without fetching authHint or running the silent-or-redirect cascade — -// keeping the OS handover decoupled from AD entirely. The ENSURE_AD branch is -// unaffected (that's the explicit AD entry point). Preview is fetched by the -// dispatcher regardless of this switch, since the region check needs it on -// every branch. +// target without fetching authHint/preview or running the silent-or-redirect +// cascade — keeping the OS handover decoupled from AD entirely. The +// ENSURE_AD branch is unaffected (that's the explicit AD entry point). const ENSURE_AD_ON_OS_HANDOVER = false; // AAD response hashes always carry one of these. Cheap pattern beats parsing @@ -246,9 +242,11 @@ const runEnsureAd = async ( config: Config, scriptUrl: URL, returnTo: string | null, - preview: Result, ): Promise => { - const authHint = await tryFetchAuthHint(scriptUrl); + const [authHint, preview] = await Promise.all([ + tryFetchAuthHint(scriptUrl), + tryFetchPreview(scriptUrl), + ]); const inFlag = FEATURE_FLAGS.shouldUseFullPageMsalRedirect({ config, preview, @@ -298,66 +296,6 @@ const runEnsureAd = async ( // for us to do. No navigation here. }; -// Allowlist for the region-redirect sink. Today the host we redirect to is -// derived from the origin we are already running on and never from the query -// string, so this cannot fail — but that reasoning lives in -// applyRegionToString, a module away, and it stops holding the moment a -// region's host comes from config, which is exactly what the front-door option -// will do. Asserting it at the sink keeps the constraint local and checkable. -const ALLOWED_REDIRECT_HOST = /^cpslon(-[a-z0-9]+)?\.outsystemsenterprise\.com$/; - -// Region override (FCT2-20670). The handover sits in the navigation path of -// every OS entry point, which makes it the natural place to catch a user who is -// on the wrong OutSystems host and move them across before anything else runs. -// -// Loop-safety rests on the preview cookie living on the polaris origin: it -// reads identically from either OS host, so the decision is stable across the -// redirect. Every non-matching case — no override, already on London, or the -// polaris-served handover variant (not an OS host at all) — leaves the origin -// untouched and returns false. -// -// The redirect has to happen before the handover does its work, not after: -// handover transfers CMS auth into the OS origin's own storage, and that does -// not cross origins. Every param carrying an OS domain is transposed too — -// returnTo in particular, which resolveReturnTo requires to be same-origin. -const redirectToPreviewRegion = ( - win: Window, - preview: Result, -): boolean => { - const region = getPreviewRegion(preview); - const url = new URL(win.location.href); - const targetOrigin = applyRegionToString(url.origin, region); - if (targetOrigin === url.origin) { - return false; - } - - const target = new URL(url.href); - target.host = new URL(targetOrigin).host; - for (const [key, value] of [...target.searchParams]) { - target.searchParams.set(key, applyRegionToString(value, region)); - } - - // Fails closed: a user who doesn't get moved to London is a broken test, not - // a broken app, so we carry on dispatching on the current host. - if ( - target.protocol !== "https:" || - !ALLOWED_REDIRECT_HOST.test(target.hostname) - ) { - console.warn( - "[CPS-GLOBAL-HANDOVER] refusing region redirect to unexpected host", - { host: target.hostname, protocol: target.protocol }, - ); - return false; - } - - console.log("[CPS-GLOBAL-HANDOVER] region override redirect", { - from: url.href, - to: target.href, - }); - win.location.replace(target.href); - return true; -}; - // Single dispatcher for the shared auth-handover endpoint. Branches by // `?stage=`; within `ad-redirect` it further branches on whether the URL // fragment carries an AAD response. @@ -381,22 +319,12 @@ export const dispatchHandover = async ( href: win.location.href, }); - // Preview rides alongside the config fetch rather than after it, so the - // region check costs no extra serial latency on a path that is already - // several redirects deep. - const [config, preview] = await Promise.all([ - getConfig(scriptUrl), - tryFetchPreview(scriptUrl), - ]); + const config = await getConfig(scriptUrl); console.log("[CPS-GLOBAL-HANDOVER] config loaded", { hasClientId: !!config.AD_CLIENT_ID, hasAuthority: !!config.AD_TENANT_AUTHORITY, }); - if (redirectToPreviewRegion(win, preview)) { - return; - } - switch (stage) { case HANDOVER_STAGES.OS_COOKIE_RETURN: { // The single door into the OS handover — both producers of a handover URL @@ -418,7 +346,7 @@ export const dispatchHandover = async ( // Cookies fresh — preemptive AD check before delivering to target. // We're already on the handover endpoint; just call the same function // the ENSURE_AD stage uses, no page-load required. - return runEnsureAd(win, config, scriptUrl, outcome.target, preview); + return runEnsureAd(win, config, scriptUrl, outcome.target); } win.location.replace(outcome.target); return; @@ -432,13 +360,15 @@ export const dispatchHandover = async ( case HANDOVER_STAGES.OS_TOKEN_RETURN: { const outcome = await handleOsTokenReturn(win, { cmsAuthStorageKeys: config.CMS_AUTH_STORAGE_KEYS!, + resetTasklistFiltersOnFreshToken: + !!config.OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN, }); // No ClientVar write here — we only arrive at this stage via the // cookie-return needs-token branch, which has already written. if (ENSURE_AD_ON_OS_HANDOVER) { // Token stored — preemptive AD check before the user reaches target. // In-process call, not a page navigation. - return runEnsureAd(win, config, scriptUrl, outcome.target, preview); + return runEnsureAd(win, config, scriptUrl, outcome.target); } win.location.replace(outcome.target); return; @@ -453,7 +383,6 @@ export const dispatchHandover = async ( config, scriptUrl, params.get(HANDOVER_PARAM_KEYS.RETURN_TO), - preview, ); case HANDOVER_STAGES.AD_REDIRECT: { diff --git a/packages/cps-global-os-handover/src/handle-os-token-return.test.ts b/packages/cps-global-os-handover/src/handle-os-token-return.test.ts index 32d443d40..56e38b613 100644 --- a/packages/cps-global-os-handover/src/handle-os-token-return.test.ts +++ b/packages/cps-global-os-handover/src/handle-os-token-return.test.ts @@ -55,22 +55,22 @@ describe("handleOsTokenReturn", () => { expect(win.location.replace).not.toHaveBeenCalled(); }); - test("calls resetTasklistFilters when new token differs from stored AND host is cps-tst", async () => { + test("calls resetTasklistFilters when new token differs from stored AND the reset is enabled", async () => { localStorage[keys.WMA_JSON] = JSON.stringify({ Cookies: "x", Token: "old-token", ExpiryTime: "x" }); const win = makeWindow( "https://cps-tst.outsystemsenterprise.com/AuthHandover/index.html?r=https%3A%2F%2Fexample.com%2FWorkManagementApp%2Fpage&stage=os-token-return&cc=test-cookies&cms-modern-token=fresh-token", ); - await handleOsTokenReturn(win, { cmsAuthStorageKeys: keys }); + await handleOsTokenReturn(win, { cmsAuthStorageKeys: keys, resetTasklistFiltersOnFreshToken: true }); expect(mockResetTasklistFilters).toHaveBeenCalledTimes(1); expect(mockResetTasklistFilters).toHaveBeenCalledWith(win); }); - test("does NOT call resetTasklistFilters when hostname is not cps-tst", async () => { + test("does NOT call resetTasklistFilters when the reset is not enabled", async () => { const win = makeWindow( - "https://cps-dev.outsystemsenterprise.com/AuthHandover/index.html?r=https%3A%2F%2Fexample.com%2FWorkManagementApp%2Fpage&stage=os-token-return&cc=test-cookies&cms-modern-token=fresh-token", + "https://cps-tst.outsystemsenterprise.com/AuthHandover/index.html?r=https%3A%2F%2Fexample.com%2FWorkManagementApp%2Fpage&stage=os-token-return&cc=test-cookies&cms-modern-token=fresh-token", ); await handleOsTokenReturn(win, { cmsAuthStorageKeys: keys }); @@ -85,7 +85,7 @@ describe("handleOsTokenReturn", () => { "https://cps-tst.outsystemsenterprise.com/AuthHandover/index.html?r=https%3A%2F%2Fexample.com%2FWorkManagementApp%2Fpage&stage=os-token-return&cc=test-cookies&cms-modern-token=same-token", ); - await handleOsTokenReturn(win, { cmsAuthStorageKeys: keys }); + await handleOsTokenReturn(win, { cmsAuthStorageKeys: keys, resetTasklistFiltersOnFreshToken: true }); expect(mockResetTasklistFilters).not.toHaveBeenCalled(); }); diff --git a/packages/cps-global-os-handover/src/handle-os-token-return.ts b/packages/cps-global-os-handover/src/handle-os-token-return.ts index b43819b10..bc81e40e5 100644 --- a/packages/cps-global-os-handover/src/handle-os-token-return.ts +++ b/packages/cps-global-os-handover/src/handle-os-token-return.ts @@ -21,7 +21,15 @@ export type OsTokenReturnOutcome = { kind: "ready"; target: string }; export const handleOsTokenReturn = async ( win: Window, - { cmsAuthStorageKeys }: { cmsAuthStorageKeys: CmsAuthStorageKeys }, + { + cmsAuthStorageKeys, + resetTasklistFiltersOnFreshToken = false, + }: { + cmsAuthStorageKeys: CmsAuthStorageKeys; + // Config-driven (OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN) rather than keyed + // off the OS hostname, so the gate survives OutSystems moving domain. + resetTasklistFiltersOnFreshToken?: boolean; + }, ): Promise => { const url = new URL(win.location.href); const [target, cookies, token] = stripParams( @@ -38,11 +46,11 @@ export const handleOsTokenReturn = async ( ); storeAuth(cookies, token, win.localStorage, cmsAuthStorageKeys); - if (didUpdateToken && win.location.hostname.startsWith("cps-tst")) { + if (didUpdateToken && resetTasklistFiltersOnFreshToken) { // FCT2-16735: a fresh token means a fresh auth context — clear stale // tasklist filters so the user lands in OS without inherited filter state - // from a previous session. Hostname check is a temporary feature gate - // limiting this to test envs. + // from a previous session. The flag is a temporary feature gate limiting + // this to test envs. resetTasklistFilters(win); } diff --git a/packages/cps-global-preview/src/App.tsx b/packages/cps-global-preview/src/App.tsx index 1e9099721..32ea5a328 100644 --- a/packages/cps-global-preview/src/App.tsx +++ b/packages/cps-global-preview/src/App.tsx @@ -25,7 +25,6 @@ type SubOption = { type RadioOption = { value: T; label: string; - disabled?: boolean; }; type Feature = { @@ -49,16 +48,6 @@ const COLOUR_PALETTE_OPTIONS: RadioOption[] = [ { value: "cps", label: "CPS" }, ]; -// Unlike the feature radios above, this group stands alone rather than hanging -// off a checkbox: "no override" is the default we want visible, not an unticked -// box. The empty value maps to an absent `region` — the same thing — so the -// cookie stays clean when nothing is overridden. -const REGION_OPTIONS: RadioOption[] = [ - { value: "", label: "No override (Dublin)" }, - { value: "london", label: "Use London" }, - { value: "frontDoor", label: "Use front-door domain", disabled: true }, -]; - const FEATURES: Feature[] = [ { key: "caseMarkers", @@ -305,15 +294,6 @@ export function App() { saveState(newState); }; - const handleRegionChange = (value: string) => { - const newState = { - ...state, - region: (value || undefined) as Preview["region"], - }; - setState(newState); - saveState(newState); - }; - const handleTextInputChange = (key: keyof Preview, value: string) => { const newState = { ...state, [key]: value || undefined }; setState(newState); @@ -795,45 +775,6 @@ export function App() { -
-
- -

Region

-
-

- Which OutSystems region the global components send you to. Applies - to the menu links, the banner title, and the auth handover — if you - are on the wrong host, the handover moves you across. Leave on{" "} - No override unless you are testing London. -

-
- {REGION_OPTIONS.map((option) => ( -
- handleRegionChange(option.value)} - /> - -
- ))} -
-
-
-
From 51f50f4834c71378ccdf54de0cf2fe9f47c89c96 Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Thu, 24 Sep 2026 15:02:28 +0100 Subject: [PATCH 2/8] #FCT2-22132 - cookie-based approach to OS oapps/dublin/london switching --- .../workflows/sub-workflow-deploy-script.yml | 14 + TODO.md | 178 +++++++++++ configuration/config.test.cps-lon.json | 280 ++++++++++++++++++ configuration/config.test.oapps.json | 280 ++++++++++++++++++ infra/analytics/kql/GloCo_PageViews.kql | 22 +- infra/proxy/README.md | 16 + .../global-components.vnext.conf | 52 ++++ .../global-components.vnext.ts | 144 +++++++++ ...lobal-components.vnext.integration.test.js | 68 +++++ .../global-components.vnext.unit.test.ts | 133 +++++++++ infra/proxy/config/main/global-components.ts | 4 + infra/proxy/config/main/nginx.js | 75 ++++- .../main/tests/global-components.unit.test.ts | 22 ++ .../config/main/tests/nginx.unit.test.ts | 93 ++++++ .../is-outsystems-app.spec.ts | 2 +- .../src/csp/derive-check-targets.spec.ts | 30 ++ .../src/csp/derive-csp.spec.ts | 5 +- .../src/is-outsystems-host.test.ts | 7 +- .../src/is-outsystems-host.ts | 6 +- .../src/outsystems-host-consistency.spec.ts | 41 ++- .../src/scripts/check-csp.ts | 20 +- .../src/scripts/os-host-variant-files.ts | 14 + .../src/validator.test.ts | 18 ++ .../cps-global-configuration/src/validator.ts | 5 +- packages/cps-global-preview/src/App.tsx | 76 +++++ scripts/config-sync-check.sh | 4 + scripts/config-sync.sh | 4 + 27 files changed, 1582 insertions(+), 31 deletions(-) create mode 100644 TODO.md create mode 100644 configuration/config.test.cps-lon.json create mode 100644 configuration/config.test.oapps.json create mode 100644 packages/cps-global-configuration/src/scripts/os-host-variant-files.ts create mode 100644 packages/cps-global-configuration/src/validator.test.ts diff --git a/.github/workflows/sub-workflow-deploy-script.yml b/.github/workflows/sub-workflow-deploy-script.yml index 25c3e6f64..90fe910ee 100644 --- a/.github/workflows/sub-workflow-deploy-script.yml +++ b/.github/workflows/sub-workflow-deploy-script.yml @@ -53,6 +53,20 @@ jobs: run: | cp ./configuration/config.${{ inputs.environment }}.json ./to-deploy/config.json + # OS host variants (config...json → config..json): + # the same config pointed at another OutSystems host. The proxy serves one + # in place of config.json to users switched onto that host (see + # infra/proxy/config/global-components.vnext). Notification files share + # the naming shape, so skip them. + for variant_file in ./configuration/config.${{ inputs.environment }}.*.json; do + [ -e "$variant_file" ] || continue + variant=$(basename "$variant_file" .json) + variant=${variant#config.${{ inputs.environment }}.} + if [ "$variant" != "notification" ]; then + cp "$variant_file" "./to-deploy/config.$variant.json" + fi + done + if [ -f "./configuration/config.${{ inputs.environment }}.notification.json" ]; then cp ./configuration/config.${{ inputs.environment }}.notification.json ./to-deploy/notification.json fi diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..4307a5923 --- /dev/null +++ b/TODO.md @@ -0,0 +1,178 @@ +# TODO: OutSystems multi-targeting (QA) and the oapps cutover + +Branch: `feature/FCT2-22099_CSP_rules`. + +## Background + +OutSystems is moving behind CPS reverse proxies, one per environment. It's a pure host swap: +same paths, same apps. + +| Env | Today | After cutover | +| ---- | ----------------------------------- | ----------------------------------- | +| dev | `cps-dev.outsystemsenterprise.com` | `oapps-dev-notprod.int.cps.gov.uk` | +| test | `cps-tst.outsystemsenterprise.com` | `oapps-qa-notprod.int.cps.gov.uk` | +| uat | `cps-tst1.outsystemsenterprise.com` | `oapps-uat-notprod.int.cps.gov.uk` (assumed shape) | +| prod | `cps.outsystemsenterprise.com` | `oapps.int.cps.gov.uk` | + +**Principle: one true OutSystems domain per environment at any given time.** +- Each env config names exactly one OS host, written out literally. +- There's no runtime host rewriting, no per-user host pins in the component, and no + multi-host alternation in context paths. +- Putting some users on a different host is done by serving them a **different config + file**, chosen by a switch that's known at C-button time (see multi-targeting below). + +### Baseline (done) + +- **`is-outsystems-host.ts`** (`packages/cps-global-configuration`) is the single answer to + "is this OutSystems?". + - It recognises `*.outsystemsenterprise.com` and `oapps(--notprod)?.int.cps.gov.uk`. + - Used by `isOutSystemsApp` (the `isOutSystems` flag and the menu/banner link adapter) and + by the CSP check targets. + - It only classifies a host, so no code changes at cutover. If UAT's real domain doesn't + fit the pattern, widen it first. +- **`outsystems-host-consistency.spec.ts`:** + - each env config names exactly one OS host across its URLs, and every OS context path + matches that host + - each variant (`config...json`) is its environment's config with only the + host swapped +- **The triage shim's activation regex** no longer depends on the host. +- **The FCT2-16735 tasklist-filter reset** is gated by + `OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN` (true in test and uat), not by a `cps-tst` + hostname check. +- **nginx `/case-review-redirect/{osHost}/{envFolder}`** accepts a full host (restricted to + `*.cps.gov.uk` / `*.outsystemsenterprise.com`) as well as the old subdomain form. +- **Removed: the London / FCT2-20670 preview-region work.** + - The component no longer loads on `cpslon*`, except test through the `cps-lon` variant. + - This doesn't matter in UAT or prod: London never ran in prod, and UAT is unrestricted. +- **CORS:** `oapps-*` hosts end in `.cps.gov.uk`, which the suffix rule in `readCorsOrigin` + already allows. They're also same-site with `polaris-*.cps.gov.uk`, so the state cookies + are first-party. + +### Multi-targeting in QA: how it works + +- **Variant configs:** + - `configuration/config.test.oapps.json` (`oapps-qa-notprod.int.cps.gov.uk`) + - `configuration/config.test.cps-lon.json` (`cpslon-tst.outsystemsenterprise.com`) + - Both deploy as `test/config..json` (deploy workflow and `config-sync*.sh`). +- **The switch** is the `Gloco-Os-Target-` cookie on the Polaris host. + - `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and its value is the OS host. + - It's per environment because dev and test share `polaris-qa-notprod`. + - Set via `PUT`/`DELETE /global-components/os-target/`, from the preview page's + "OutSystems host" control. Anyone with access to the preview page can switch. +- **The vnext proxy** (`infra/proxy/config/global-components.vnext`): + - `OS_HOST_VARIANTS` maps each variant to its host. A unit test checks it against the + variant files in both directions. + - `/global-components//config.json` serves, in order of preference: the variant for + the requesting OS page's own host (by `Origin`); the variant the cookie names (CWA); or + `config.json`. + - Config always matches the page it's running on, because auth state is per origin. +- **Polaris `/init` (`appAuthRedirect`)** is where every route into OutSystems passes + through: the C-button, CWA links and case-review. It forces Edge mode, so it can read the + cookie. + - Drafted as `_toOsTarget` in our copy, `infra/proxy/config/main/nginx.js`. + - It works out the environment from the handover's `src`, then swaps the host in `r` and + in its nested destination. + - It only uses the swap if `AUTH_HANDOVER_WHITELIST` accepts it, so a stale cookie never + causes a 403. + - Polaris's `/launch/*` doesn't change. +- **Component and handover code are unchanged.** +- **The live CSP checker** also probes the variant hosts (`test.oapps`, `test.cps-lon`). +- **Analytics:** `GloCo_PageViews.kql` folds `cps.outsystemsenterprise.com` and + `oapps.int.cps.gov.uk` into one `OsUrl` prefix, so history is continuous. It's in the + repo, not yet deployed. + +### Reference: AAD redirect URIs + +App reg `8d6133af-9593-47c6-94d0-5c65e9e310f1` needs both forms as SPA redirect URIs for +each host (per `packages/cps-global-handover/EXTERNAL-ENTRY.md`): + +``` +https:///Casework_Patterns/auth-handover.html?src= +https:///Casework_Patterns/auth-handover.html?src=&stage=ad-redirect +``` + +`` for each environment: +- dev: `https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Fdev%2Fauth-handover.js` +- test: `https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js` +- uat: `https%3A%2F%2Fpolaris-uat-notprod.cps.gov.uk%2Fglobal-components%2Fuat%2Fauth-handover.js` +- prod: `https%3A%2F%2Fpolaris.cps.gov.uk%2Fglobal-components%2Fprod%2Fauth-handover.js` + +## QA multi-targeting: to get it working on test + +- [ ] Run the proxy integration tests: `pnpm -w test:proxy`. They include six new vnext + tests; none have been run yet. +- [ ] **Polaris PR for `/init`:** transpose `_toOsTarget` and the `appAuthRedirect` change + from `infra/proxy/config/main/nginx.js`, plus the "OS switch" tests in + `nginx.unit.test.ts`. Note that njs has no destructuring and no `URL` class. +- [ ] **Polaris PR for main-conf CORS:** add `https://cpslon-tst.outsystemsenterprise.com` + to `CORS_ALLOWED_ORIGINS` in `infra/proxy/config/main/global-components.ts`. +- [ ] Deploy the vnext layer (`infra/proxy/deploy/deploy.sh`). +- [ ] Deploy the variant configs, via the release workflow or `scripts/config-sync.sh`. +- [ ] **External entries,** for both `oapps-qa-notprod.int.cps.gov.uk` and + `cpslon-tst.outsystemsenterprise.com`: + - [ ] AAD redirect URIs, both forms, with the test `src` (see the reference above). + - [ ] `AUTH_HANDOVER_WHITELIST`: add `https:///Casework_Patterns/auth-handover.html`. + - [ ] `/api/cms-modern-token` backend (behind `/auth-refresh-cms-modern-token`): confirm it + accepts a return URL on ``, or add it to the allowlist if there is one. + - [ ] Confirm `https:///Casework_Patterns/auth-handover.html` is served. +- [ ] **Notifications:** `configuration/config.*.notification.json` `urlRegex` values + hard-code `\\.outsystemsenterprise\\.com`, so they won't match on oapps pages. Make + them host-agnostic. +- [ ] **Trial on test:** + - switch via the preview page + - C-button, CWA menu links, case-review redirect + - cold-cache MSAL redirect + - switching back to Default + +## Presence on oapps: to discuss + +- On oapps pages (same-site with Polaris), the `SameSite=Lax` `cms-auth-presence-token` + cookie (`Path=/global-components/case-locking`, set by the CMS-estate auth callback) will + start being sent with the component's requests. +- `presenceBearer` still lets a client `Authorization` header win. The difference is when + the component has **no** MSAL token: today that negotiate request gets a 401; on oapps it + would authenticate with the CMS-estate token instead. +- The component uses SSE or long polling, so after negotiate it talks to `/api/sr/`, which + never reads the cookie. So only negotiate and REST calls are affected. +- **Options:** + 1. Accept it, and update the comment in `global-components.case-locking.ts`. + 2. *(leaning)* Don't open the hub connection without a token (`case-locking-presence.ts`). + 3. Only let `presenceBearer` fall back to the cookie for CMS-estate callers, by `Origin`. + This is fragile, since CMS hosts are under `.cps.gov.uk` too. +- **Open question:** does the presence cookie end up in Edge's cookie store or IE mode's? + If IE mode, the case never happens. + +## Chunk 2: the per-environment cutover to oapps + +For each environment: + +- [ ] External entries: AAD URIs (with that environment's `src`), the whitelist entry, the + cms-modern-token backend, and confirming the handover page is served on the new host. +- [ ] `configuration/config..json`: find-and-replace the OS host, including the + regex-escaped form in context paths (e.g. `cps-tst\\.outsystemsenterprise\\.com`). + The consistency spec catches misses. +- [ ] Polaris `/launch/*` (their nginx; `infra/proxy/config/main/nginx-full.conf` here is a + reference copy, not deployed): repoint the hard-coded handover and the double-encoded + destination. +- [ ] Polaris terraform `case_review_app_redirect_url`: change to + `/case-review-redirect//`. +- [ ] Re-run the CSP tooling (`generate:csp` / `check:csp` in `cps-global-configuration`). +- [ ] After the old host is retired: remove its `CORS_ALLOWED_ORIGINS` entry. +- [ ] Prod only: deploy the `GloCo_PageViews.kql` change before or with the prod cutover. + +What to expect on the day: + +- Every user starts with empty per-origin state on the new host: MSAL cache, OS ClientVars + and CMS auth. + - The first OS page load does one AAD round trip, through the new host's handover. + - CMS auth arrives through the C-button / handover chain. +- Old-host bookmarks stop getting the component once the config moves. If the old host + stays live, that's for OutSystems or infra to handle with a redirect, not for multiple + hosts in our config. + +Open questions: + +- Will the old OS hosts redirect to oapps at cutover? + - If yes: flip our config at cutover, and Polaris updates `/launch` whenever convenient. + - If no: our config flip and Polaris's `/launch` change have to go out together. +- Does the config flip ship through the release workflow or `config-sync.sh`? diff --git a/configuration/config.test.cps-lon.json b/configuration/config.test.cps-lon.json new file mode 100644 index 000000000..a67a84abf --- /dev/null +++ b/configuration/config.test.cps-lon.json @@ -0,0 +1,280 @@ +{ + "ENVIRONMENT": "test", + "CMS_AUTH_STORAGE_KEYS": { + "WMA_JSON": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "WMA_COOKIES": "$OS_Users$Casework_Blocks$ClientVars$Cookies", + "CASE_REVIEW_JSON": "$OS_Users$CaseReview$ClientVars$CmsAuthValues", + "CASE_REVIEW_COOKIES": "$OS_Users$CaseReview$ClientVars$Cookies", + "HOME_JSON": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "HOME_COOKIES": "$OS_Users$Casework_Blocks$ClientVars$Cookies", + "HOME_IS_FROM_PROXY": "$OS_Users$Casework_Blocks$ClientVars$IsFromProxy", + "VCA_JSON": "$OS_Users$VictimsCaseApplication$ClientVars$JSONString", + "VCA_COOKIES": "$OS_Users$VictimsCaseApplication$ClientVars$Cookies" + }, + "REDIRECT_SCRIPT_URL": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/global-components.js", + "CASE_LOCKING_API_URL": "https://polaris-qa-notprod.cps.gov.uk/global-components/case-locking/api/hubs/notifications", + "CASE_LOCKING_SCOPES": ["api://8d6133af-9593-47c6-94d0-5c65e9e310f1/api.presence.user.readwrite"], + "CASE_LOCKING_CASE_DETAILS_URL": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}", + "FEATURE_FLAG_CASE_LOCKING_USERS": { + "generallyAvailable": true + }, + "BANNER_TITLE_HREF": "https://cpslon-tst.outsystemsenterprise.com/Casework/home", + "AD_TENANT_AUTHORITY": "https://login.microsoftonline.com/00dd0d1d-d7e6-4338-ac51-565339c7088c", + "AD_CLIENT_ID": "8d6133af-9593-47c6-94d0-5c65e9e310f1", + "AD_GATEWAY_SCOPES": ["User.Read"], + "GATEWAY_URL": "https://polaris-qa-notprod.cps.gov.uk/", + "APP_INSIGHTS_CONNECTION_STRING": "InstrumentationKey=e572c03c-8d38-4771-b193-962f13da1b1a;IngestionEndpoint=https://polaris-qa-notprod.cps.gov.uk/global-components/analytics/;ApplicationId=3dafc37d-8c9c-4480-90fc-532ac2b8bba2", + "SURVEY_LINK": "https://forms.cloud.microsoft/Pages/ResponsePage.aspx?id=HQ3dAObXOEOsUVZTOccIjMYFiPHggxZNtgEAXRbAx2hUNjNWUDc0UEtVTERCMFpFMkI1QTVSSUxTVyQlQCN0PWcu&rc715f3f34a484d8eae0a1809b30c6c5a=%22Feedback%22&r67e8aaf2389e4b9d8aa0c01902281211=9&rf690462c782847db972938cb1c773bc2=%22Easy%22&r781f1b5b16b14f5689b788fa6d98287b=%22Make%20a%20Charging%20Decision%22", + "REPORT_ISSUE_LINK": "https://forms.office.com/Pages/ResponsePage.aspx?id=HQ3dAObXOEOsUVZTOccIjJoWrYUO2RtKg-k_WWF_5qZUMFhXNTRVNkowWDhXSzFMOUc5SVhQQlVMSy4u", + "ACCESSIBILITY_STATEMENT_URL": "https://cpslon-tst.outsystemsenterprise.com/Casework/accessibility", + "SHOW_MENU": true, + "SHOW_RECENT_CASES": true, + "SHOW_MONITORING_CODES": false, + "SHOW_NOTIFICATIONS": true, + "SHOW_HEADER_REBRAND": "cps", + "OS_HANDOVER_URL": "https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js", + "OS_ENTRA_ID_STORAGE_KEY": "$OS_Users$Casework_Blocks$ClientVars$EntraID", + "OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN": true, + "RECENT_CASES_NAVIGATE_URL": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}", + "RECENT_CASES_LIST_LENGTH": 5, + "FEATURE_FLAG_MENU_USERS": { + "generallyAvailable": true + }, + "FEATURE_FLAG_USE_MSAL_FULL_REDIRECT_USERS": { + "generallyAvailable": true + }, + "OS_TRIAGE_REQUEST_OBSERVATION_ENABLED": true, + "PROBE_DARK_READER_USAGE": true, + "FOOTER_SHIM_ENABLED": true, + "FEATURE_FLAG_ACCESSIBILITY_MODE_USERS": { + "generallyAvailable": true + }, + "SSO_SILENT_DELAY_MS": 2000, + "USER_DATA_REFRESH_PERIOD_MINS": 5, + "PROBE_NAVIGATOR_PERMISSIONS_REFRESH_PERIOD_MINS": 120, + "CONTEXTS": [ + { + "headerCustomCssClasses": "header", + "headerCustomCssStyles": { + "background-color": "transparent", + "box-shadow": "none" + }, + "skipLinks": { + "mainSelector": ".cps-skip-link__target", + "searchSelector": ".cps-skip-link__target .cps-filter-layout .cps-search-form", + "listSelector": ".cps-skip-link__target .cps-filter-layout .cps-search-results", + "useScroll": true + }, + "msalRedirectUrl": "https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js&stage=ad-redirect", + "cmsAuthFromStorageKey": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "contexts": [ + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/casework(_blocks)?/home", + "contextIds": "home", + "showNotification": true + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/casework(_blocks)?(?![a-z0-9_])", + "contextIds": "unknown-home" + }, + { + "caseLockingAppName": "Case Review App", + "contexts": [ + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", + "contextIds": "case review", + "caseLockingRegion": { "code": "case_review" }, + "hostAppEventTargets": [ + { "selector": "#b1-Resume", "action": "click" }, + { "selector": "#b1-TaskCompleted2", "action": "appear" } + ] + } + ] + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/CaseReview/RedirectCW", + "contextIds": "redirect", + "preventPageViewAnalytics": true + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/CaseReview/", + "contextIds": "cases" + }, + { + "caseLockingAppName": "Work Management App", + "contexts": [ + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", + "contextIds": "case materials", + "caseLockingRegion": { "code": "case" } + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", + "contextIds": "case details", + "caseLockingRegion": { "code": "victim_witness", "subject": "{witnessId}" } + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp.*[&?]CaseId=(?\\d+)", + "contextIds": "case details", + "caseLockingRegion": { "code": "case" } + } + ] + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp/Cases", + "contextIds": "cases" + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp/TaskList", + "contextIds": "tasks" + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp/Redirect", + "contextIds": "redirect", + "preventPageViewAnalytics": true + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp/SearchCase", + "contextIds": "cases" + }, + { + "path": "https://cpslon-tst\\.outsystemsenterprise\\.com/WorkManagementApp", + "contextIds": "unknown-wma" + } + ] + }, + { + "msalRedirectUrl": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js&stage=ad-redirect", + "contexts": [ + { + "caseLockingAppName": "Casework App", + "contexts": [ + { + "path": "https://polaris-qa-notprod.cps.gov.uk/(?:polaris-ui/case-details|materials-ui)/(?[^/]+)/(?\\d+)", + "contextIds": "case materials materials-cwa", + "caseLockingRegion": { "code": "case" } + } + ] + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/(?:polaris-ui|materials-ui)/go\\?", + "contextIds": "redirect materials-cwa", + "preventPageViewAnalytics": true + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/accessibility/settings\\.html", + "contextIds": "accessibility-settings", + "preventADAndDataCalls": true + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/accessibility/", + "contextIds": "accessibility-statement", + "preventADAndDataCalls": true + }, + { + "path": "https://polaris", + "contextIds": "cases materials-search materials-cwa" + } + ] + }, + { + "msalRedirectUrl": "https://housekeeping-staging.int.cps.gov.uk/global-components-msal-redirect.html", + "contexts": [ + { + "path": "https://housekeeping-staging.int.cps.gov.uk/init", + "contextIds": "redirect", + "preventADAndDataCalls": true, + "preventPageViewAnalytics": true, + "takeTagsFromHandover": true + }, + { + "caseLockingAppName": "Casework App", + "contexts": [ + { + "path": "https://housekeeping", + "contextIds": "case materials", + "caseLockingRegion": { "code": "case" }, + "preventADAndDataCalls": true, + "takeTagsFromHandover": true + } + ] + } + ] + }, + { + "msalRedirectUrl": "https://lacc-app-ui-spa-staging.azurewebsites.net/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js", + "contexts": [ + { + "path": "https://lacc-app-ui-spa", + "contextIds": "lcc" + } + ] + }, + { + "msalRedirectUrl": "{scheme}://localhost", + "contexts": [ + { + "path": "(?https?)://localhost", + "contextIds": "fallback" + } + ] + } + ], + "LINKS": [ + { + "label": "Home", + "level": 0, + "href": "https://cpslon-tst.outsystemsenterprise.com/Casework/home", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "home" + }, + { + "label": "Tasks", + "level": 0, + "href": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/TaskList", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "tasks" + }, + { + "label": "Cases", + "level": 0, + "href": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/Cases?IsCasesNavigation=true", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "cases case" + }, + { + "label": "Details", + "level": 1, + "href": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}", + "visibleContexts": "case", + "activeContexts": "details", + "dcfContextsToUseEventNavigation": { + "contexts": "materials", + "data": "details", + "paramsToAddToQuery": { "IsOpenMaterials": null }, + "waitingBehaviour": "disabled" + } + }, + { + "label": "Materials", + "level": 1, + "href": "https://polaris-qa-notprod.cps.gov.uk/auth-refresh-outbound?q=%7B%22caseId%22%3A{caseId}%7D", + "dcfHref": "https://cpslon-tst.outsystemsenterprise.com/WorkManagementApp/CaseOverview?CaseId={caseId}&IsOpenMaterials=true", + "visibleContexts": "case", + "activeContexts": "materials", + "dcfContextsToUseEventNavigation": { + "contexts": "details", + "data": "materials", + "paramsToAddToQuery": { "IsOpenMaterials": "true" }, + "waitingBehaviour": "disabled" + } + }, + { + "label": "Review", + "level": 1, + "href": "https://cpslon-tst.outsystemsenterprise.com/CaseReview/LandingPage?CMSCaseId={caseId}", + "visibleContexts": "case", + "activeContexts": "review" + } + ] +} diff --git a/configuration/config.test.oapps.json b/configuration/config.test.oapps.json new file mode 100644 index 000000000..5a8b95e4f --- /dev/null +++ b/configuration/config.test.oapps.json @@ -0,0 +1,280 @@ +{ + "ENVIRONMENT": "test", + "CMS_AUTH_STORAGE_KEYS": { + "WMA_JSON": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "WMA_COOKIES": "$OS_Users$Casework_Blocks$ClientVars$Cookies", + "CASE_REVIEW_JSON": "$OS_Users$CaseReview$ClientVars$CmsAuthValues", + "CASE_REVIEW_COOKIES": "$OS_Users$CaseReview$ClientVars$Cookies", + "HOME_JSON": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "HOME_COOKIES": "$OS_Users$Casework_Blocks$ClientVars$Cookies", + "HOME_IS_FROM_PROXY": "$OS_Users$Casework_Blocks$ClientVars$IsFromProxy", + "VCA_JSON": "$OS_Users$VictimsCaseApplication$ClientVars$JSONString", + "VCA_COOKIES": "$OS_Users$VictimsCaseApplication$ClientVars$Cookies" + }, + "REDIRECT_SCRIPT_URL": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/global-components.js", + "CASE_LOCKING_API_URL": "https://polaris-qa-notprod.cps.gov.uk/global-components/case-locking/api/hubs/notifications", + "CASE_LOCKING_SCOPES": ["api://8d6133af-9593-47c6-94d0-5c65e9e310f1/api.presence.user.readwrite"], + "CASE_LOCKING_CASE_DETAILS_URL": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/CaseOverview?CaseId={caseId}", + "FEATURE_FLAG_CASE_LOCKING_USERS": { + "generallyAvailable": true + }, + "BANNER_TITLE_HREF": "https://oapps-qa-notprod.int.cps.gov.uk/Casework/home", + "AD_TENANT_AUTHORITY": "https://login.microsoftonline.com/00dd0d1d-d7e6-4338-ac51-565339c7088c", + "AD_CLIENT_ID": "8d6133af-9593-47c6-94d0-5c65e9e310f1", + "AD_GATEWAY_SCOPES": ["User.Read"], + "GATEWAY_URL": "https://polaris-qa-notprod.cps.gov.uk/", + "APP_INSIGHTS_CONNECTION_STRING": "InstrumentationKey=e572c03c-8d38-4771-b193-962f13da1b1a;IngestionEndpoint=https://polaris-qa-notprod.cps.gov.uk/global-components/analytics/;ApplicationId=3dafc37d-8c9c-4480-90fc-532ac2b8bba2", + "SURVEY_LINK": "https://forms.cloud.microsoft/Pages/ResponsePage.aspx?id=HQ3dAObXOEOsUVZTOccIjMYFiPHggxZNtgEAXRbAx2hUNjNWUDc0UEtVTERCMFpFMkI1QTVSSUxTVyQlQCN0PWcu&rc715f3f34a484d8eae0a1809b30c6c5a=%22Feedback%22&r67e8aaf2389e4b9d8aa0c01902281211=9&rf690462c782847db972938cb1c773bc2=%22Easy%22&r781f1b5b16b14f5689b788fa6d98287b=%22Make%20a%20Charging%20Decision%22", + "REPORT_ISSUE_LINK": "https://forms.office.com/Pages/ResponsePage.aspx?id=HQ3dAObXOEOsUVZTOccIjJoWrYUO2RtKg-k_WWF_5qZUMFhXNTRVNkowWDhXSzFMOUc5SVhQQlVMSy4u", + "ACCESSIBILITY_STATEMENT_URL": "https://oapps-qa-notprod.int.cps.gov.uk/Casework/accessibility", + "SHOW_MENU": true, + "SHOW_RECENT_CASES": true, + "SHOW_MONITORING_CODES": false, + "SHOW_NOTIFICATIONS": true, + "SHOW_HEADER_REBRAND": "cps", + "OS_HANDOVER_URL": "https://oapps-qa-notprod.int.cps.gov.uk/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js", + "OS_ENTRA_ID_STORAGE_KEY": "$OS_Users$Casework_Blocks$ClientVars$EntraID", + "OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN": true, + "RECENT_CASES_NAVIGATE_URL": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/CaseOverview?CaseId={caseId}", + "RECENT_CASES_LIST_LENGTH": 5, + "FEATURE_FLAG_MENU_USERS": { + "generallyAvailable": true + }, + "FEATURE_FLAG_USE_MSAL_FULL_REDIRECT_USERS": { + "generallyAvailable": true + }, + "OS_TRIAGE_REQUEST_OBSERVATION_ENABLED": true, + "PROBE_DARK_READER_USAGE": true, + "FOOTER_SHIM_ENABLED": true, + "FEATURE_FLAG_ACCESSIBILITY_MODE_USERS": { + "generallyAvailable": true + }, + "SSO_SILENT_DELAY_MS": 2000, + "USER_DATA_REFRESH_PERIOD_MINS": 5, + "PROBE_NAVIGATOR_PERMISSIONS_REFRESH_PERIOD_MINS": 120, + "CONTEXTS": [ + { + "headerCustomCssClasses": "header", + "headerCustomCssStyles": { + "background-color": "transparent", + "box-shadow": "none" + }, + "skipLinks": { + "mainSelector": ".cps-skip-link__target", + "searchSelector": ".cps-skip-link__target .cps-filter-layout .cps-search-form", + "listSelector": ".cps-skip-link__target .cps-filter-layout .cps-search-results", + "useScroll": true + }, + "msalRedirectUrl": "https://oapps-qa-notprod.int.cps.gov.uk/Casework_Patterns/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js&stage=ad-redirect", + "cmsAuthFromStorageKey": "$OS_Users$Casework_Blocks$ClientVars$JSONString", + "contexts": [ + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/casework(_blocks)?/home", + "contextIds": "home", + "showNotification": true + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/casework(_blocks)?(?![a-z0-9_])", + "contextIds": "unknown-home" + }, + { + "caseLockingAppName": "Case Review App", + "contexts": [ + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/CaseReview.*?[&?](?:CMS)?CaseId=(?\\d+)", + "contextIds": "case review", + "caseLockingRegion": { "code": "case_review" }, + "hostAppEventTargets": [ + { "selector": "#b1-Resume", "action": "click" }, + { "selector": "#b1-TaskCompleted2", "action": "appear" } + ] + } + ] + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/CaseReview/RedirectCW", + "contextIds": "redirect", + "preventPageViewAnalytics": true + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/CaseReview/", + "contextIds": "cases" + }, + { + "caseLockingAppName": "Work Management App", + "contexts": [ + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp.*?(?=.*[&?]CaseId=(?\\d+))(?=.*[&?]IsOpenMaterials=true)", + "contextIds": "case materials", + "caseLockingRegion": { "code": "case" } + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp/CaseOverviewDetails(?=.*[&?]In_WitnessID=(?\\d+))(?=.*[&?]CaseId=(?\\d+))", + "contextIds": "case details", + "caseLockingRegion": { "code": "victim_witness", "subject": "{witnessId}" } + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp.*[&?]CaseId=(?\\d+)", + "contextIds": "case details", + "caseLockingRegion": { "code": "case" } + } + ] + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp/Cases", + "contextIds": "cases" + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp/TaskList", + "contextIds": "tasks" + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp/Redirect", + "contextIds": "redirect", + "preventPageViewAnalytics": true + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp/SearchCase", + "contextIds": "cases" + }, + { + "path": "https://oapps-qa-notprod\\.int\\.cps\\.gov\\.uk/WorkManagementApp", + "contextIds": "unknown-wma" + } + ] + }, + { + "msalRedirectUrl": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js&stage=ad-redirect", + "contexts": [ + { + "caseLockingAppName": "Casework App", + "contexts": [ + { + "path": "https://polaris-qa-notprod.cps.gov.uk/(?:polaris-ui/case-details|materials-ui)/(?[^/]+)/(?\\d+)", + "contextIds": "case materials materials-cwa", + "caseLockingRegion": { "code": "case" } + } + ] + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/(?:polaris-ui|materials-ui)/go\\?", + "contextIds": "redirect materials-cwa", + "preventPageViewAnalytics": true + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/accessibility/settings\\.html", + "contextIds": "accessibility-settings", + "preventADAndDataCalls": true + }, + { + "path": "https://polaris-qa-notprod.cps.gov.uk/global-components/test/accessibility/", + "contextIds": "accessibility-statement", + "preventADAndDataCalls": true + }, + { + "path": "https://polaris", + "contextIds": "cases materials-search materials-cwa" + } + ] + }, + { + "msalRedirectUrl": "https://housekeeping-staging.int.cps.gov.uk/global-components-msal-redirect.html", + "contexts": [ + { + "path": "https://housekeeping-staging.int.cps.gov.uk/init", + "contextIds": "redirect", + "preventADAndDataCalls": true, + "preventPageViewAnalytics": true, + "takeTagsFromHandover": true + }, + { + "caseLockingAppName": "Casework App", + "contexts": [ + { + "path": "https://housekeeping", + "contextIds": "case materials", + "caseLockingRegion": { "code": "case" }, + "preventADAndDataCalls": true, + "takeTagsFromHandover": true + } + ] + } + ] + }, + { + "msalRedirectUrl": "https://lacc-app-ui-spa-staging.azurewebsites.net/auth-handover.html?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js", + "contexts": [ + { + "path": "https://lacc-app-ui-spa", + "contextIds": "lcc" + } + ] + }, + { + "msalRedirectUrl": "{scheme}://localhost", + "contexts": [ + { + "path": "(?https?)://localhost", + "contextIds": "fallback" + } + ] + } + ], + "LINKS": [ + { + "label": "Home", + "level": 0, + "href": "https://oapps-qa-notprod.int.cps.gov.uk/Casework/home", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "home" + }, + { + "label": "Tasks", + "level": 0, + "href": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/TaskList", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "tasks" + }, + { + "label": "Cases", + "level": 0, + "href": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/Cases?IsCasesNavigation=true", + "visibleContexts": "home tasks cases case unknown-wma accessibility-settings", + "activeContexts": "cases case" + }, + { + "label": "Details", + "level": 1, + "href": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/CaseOverview?CaseId={caseId}", + "visibleContexts": "case", + "activeContexts": "details", + "dcfContextsToUseEventNavigation": { + "contexts": "materials", + "data": "details", + "paramsToAddToQuery": { "IsOpenMaterials": null }, + "waitingBehaviour": "disabled" + } + }, + { + "label": "Materials", + "level": 1, + "href": "https://polaris-qa-notprod.cps.gov.uk/auth-refresh-outbound?q=%7B%22caseId%22%3A{caseId}%7D", + "dcfHref": "https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/CaseOverview?CaseId={caseId}&IsOpenMaterials=true", + "visibleContexts": "case", + "activeContexts": "materials", + "dcfContextsToUseEventNavigation": { + "contexts": "details", + "data": "materials", + "paramsToAddToQuery": { "IsOpenMaterials": "true" }, + "waitingBehaviour": "disabled" + } + }, + { + "label": "Review", + "level": 1, + "href": "https://oapps-qa-notprod.int.cps.gov.uk/CaseReview/LandingPage?CMSCaseId={caseId}", + "visibleContexts": "case", + "activeContexts": "review" + } + ] +} diff --git a/infra/analytics/kql/GloCo_PageViews.kql b/infra/analytics/kql/GloCo_PageViews.kql index 50e92e8cf..cfa357447 100644 --- a/infra/analytics/kql/GloCo_PageViews.kql +++ b/infra/analytics/kql/GloCo_PageViews.kql @@ -2,25 +2,31 @@ AppPageViews | project TimeGenerated, OperationId, Id, Url, SessionId, UserId, DurationMs, parsed = parse_json(Properties) | extend Environment = tostring(parsed.Environment) | where Environment == "prod" -| where Url !startswith "https://cps.outsystemsenterprise.com/WorkManagementApp/Redirect" +// OutSystems is moving from cps.outsystemsenterprise.com to the oapps proxy +// (oapps.int.cps.gov.uk). History stays on the old host, so both are folded into one +// "os:" prefix here and every OS filter/classification below matches either. Url +// itself is left untouched. (?i) because startswith, which this replaces, is +// case-insensitive. +| extend OsUrl = replace_regex(Url, @"(?i)^https://(cps\.outsystemsenterprise\.com|oapps\.int\.cps\.gov\.uk)/", "os:/") +| where OsUrl !startswith "os:/WorkManagementApp/Redirect" | where Url !startswith "https://polaris.cps.gov.uk/global-components/prod/preview/index.html" | where Url !in ("https://polaris.cps.gov.uk/polaris-ui", "https://polaris.cps.gov.uk/polaris-ui/") | where Url !startswith "https://polaris.cps.gov.uk/polaris-ui/go" | where Url !startswith "https://polaris.cps.gov.uk/polaris-ui#code" -| where Url !startswith "https://cps.outsystemsenterprise.com/CaseReview/RedirectCW" -| where Url !startswith "https://cps.outsystemsenterprise.com/CaseReview/LoginPage" +| where OsUrl !startswith "os:/CaseReview/RedirectCW" +| where OsUrl !startswith "os:/CaseReview/LoginPage" | where Url !startswith "https://housekeeping.cps.gov.uk/init" // Nested double-encoded objects — parse_json() again on each | extend Auth = parse_json(tostring(parsed.Auth)) | extend App = case( Url startswith "https://polaris.", "Casework app", Url startswith "https://housekeeping", "Housekeeping", - Url startswith "https://cps.outsystemsenterprise.com/CaseReview", "Case review", - Url startswith "https://cps.outsystemsenterprise.com/WorkManagementApp", "Work management app", + OsUrl startswith "os:/CaseReview", "Case review", + OsUrl startswith "os:/WorkManagementApp", "Work management app", // startswith is case-insensitive. Home moved module Casework_Blocks -> Casework // (FCT2-19128); both spellings stay live while the old URL is still linked-to. - Url startswith "https://cps.outsystemsenterprise.com/casework_blocks" - or Url startswith "https://cps.outsystemsenterprise.com/casework/", "Home", + OsUrl startswith "os:/casework_blocks" + or OsUrl startswith "os:/casework/", "Home", "Unknown") | extend Feature = iff(App in ("Case review", "Work management app", "Casework app"), tostring(split(parse_url(Url)["Path"], "/")[2]), App) | extend Build = parse_json(tostring(parsed.Build)) @@ -86,4 +92,4 @@ AppPageViews Auth_Department = iif(isnotempty(Auth_Department), Auth_Department, Department), Auth_JobTitle = iif(isnotempty(Auth_JobTitle), Auth_JobTitle, JobTitle) | project-away Email, UserArea, UserAreaOrCPSD, Department, Region, JobTitle -| project-away parsed, Build, Auth, Context, CorrelationIds, User +| project-away parsed, Build, Auth, Context, CorrelationIds, User, OsUrl diff --git a/infra/proxy/README.md b/infra/proxy/README.md index 3ffccbb12..f99c29300 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -22,6 +22,22 @@ Nginx reverse proxy with njs (JavaScript) for header/cookie manipulation. Used t - `.env` - **gitignored** - contains vnext-specific config - `.env.example` - template for the above +The vnext layer also owns **OS host variants**: letting named users be switched onto a +different OutSystems host from the rest of their environment. + +- `configuration/config...json` is the environment's config with only the OS + host swapped (enforced by `outsystems-host-consistency.spec.ts`), deployed beside + `config.json` as `config..json`. +- `OS_HOST_VARIANTS` in `global-components.vnext.ts` maps each variant to its host. A unit + test checks it against the variant files both ways. +- The switch is the `Gloco-Os-Target-` cookie (`Path=/`, value = the variant's OS host), + set via `PUT`/`DELETE /global-components/os-target/` from the preview page. +- `/global-components//config.json` is served by `readConfigBlobName`: + - the variant for the requesting OS page's own host (by `Origin`); else + - the variant the cookie names (CWA); else + - `config.json`. +- Polaris's `/init` reads the same cookie to move the handover onto the switched host. + ### Docker (`docker/`) - `Dockerfile.base` - nginx with njs module, runs as non-root `nginx` user on port 8080 diff --git a/infra/proxy/config/global-components.vnext/global-components.vnext.conf b/infra/proxy/config/global-components.vnext/global-components.vnext.conf index 08c7f38fb..2e899e7ef 100644 --- a/infra/proxy/config/global-components.vnext/global-components.vnext.conf +++ b/infra/proxy/config/global-components.vnext/global-components.vnext.conf @@ -36,3 +36,55 @@ location = /global-components/validate-token { js_content glocovnext.handleValidateToken; } +# --------------------------------------------------------------------------- +# OS host variants (see OS_HOST_VARIANTS in global-components.vnext.ts) +# +# config.json is served as config.json or config..json, chosen by +# readConfigBlobName from the page's Origin (OS pages) or the switch cookie +# (CWA). Exact-match locations win over the main conf's regex location for +# /global-components//..., so these take over config.json alone and leave +# every other artifact where it is. Each records its environment and hands over +# to one internal location, rather than repeating the proxy block per env. +# --------------------------------------------------------------------------- +js_set $gloco_config_blob glocovnext.readConfigBlobName; + +location = /global-components/dev/config.json { set $gloco_config_env dev; rewrite ^ /global-components/__config last; } +location = /global-components/test/config.json { set $gloco_config_env test; rewrite ^ /global-components/__config last; } +location = /global-components/uat/config.json { set $gloco_config_env uat; rewrite ^ /global-components/__config last; } +location = /global-components/prod/config.json { set $gloco_config_env prod; rewrite ^ /global-components/__config last; } + +location = /global-components/__config { + internal; + + error_page 418 = @gloco_preflight; + if ($request_method = OPTIONS) { + return 418; + } + + # As the main conf's artifact location (see its comments for the reasoning). + gzip on; + gzip_types application/json; + gzip_proxied any; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 5; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Credentials; + add_header Access-Control-Allow-Origin * always; + # The body depends on who is asking, so no shared cache may reuse it across them. + add_header Vary "Origin, Cookie" always; + add_header X-Gloco-Config $gloco_config_blob always; + + resolver ${WEBSITE_DNS_SERVER} ipv6=off valid=30s; + proxy_ssl_server_name on; + proxy_ssl_verify off; + proxy_pass_request_headers off; + proxy_set_header Host ${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}; + proxy_pass https://${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}/$gloco_config_env/$gloco_config_blob; +} + +# The switch itself, driven by the preview page (same-origin, so no CORS). +location ~ ^/global-components/os-target/(dev|test|uat|prod)$ { + js_content glocovnext.handleOsTarget; +} diff --git a/infra/proxy/config/global-components.vnext/global-components.vnext.ts b/infra/proxy/config/global-components.vnext/global-components.vnext.ts index 1d8396376..3b0c9e03b 100644 --- a/infra/proxy/config/global-components.vnext/global-components.vnext.ts +++ b/infra/proxy/config/global-components.vnext/global-components.vnext.ts @@ -118,6 +118,147 @@ function handleStatus(r: NginxHTTPRequest): void { r.return(200, JSON.stringify(response)); } +// --------------------------------------------------------------------------- +// OS host variants +// +// Each environment has one OutSystems host, named literally in its +// configuration/config..json. A variant is the same config pointed at +// another OS host (configuration/config...json, deployed beside +// config.json as config..json), so named users can be switched onto +// that host while everyone else stays put. +// +// The switch is a per-environment cookie on this (polaris) host, Path=/, whose +// value is the variant's OS host. Two things read it: +// - the config route below, so the component on CWA gets the variant config +// (its links then point at the variant host); +// - Polaris's /init (appAuthRedirect), which every route into OutSystems +// passes through — C-button, CWA links, case-review — and which moves the +// handover onto the cookie's host. That lives in Polaris's nginx, and relies +// on AUTH_HANDOVER_WHITELIST to vouch for the host, hence the cookie holding +// a host rather than a variant name. +// +// Must match the variant files in configuration/ — the unit tests check both +// ways. +// --------------------------------------------------------------------------- + +const OS_HOST_VARIANTS: Record> = { + test: { + oapps: "oapps-qa-notprod.int.cps.gov.uk", + "cps-lon": "cpslon-tst.outsystemsenterprise.com", + }, +}; + +const OS_TARGET_COOKIE_PREFIX = "Gloco-Os-Target-"; +const OS_TARGET_COOKIE_LIFESPAN_MS = 365 * 24 * 60 * 60 * 1000; + +function _getCookie(r: NginxHTTPRequest, name: string): string | undefined { + const cookies = (r.headersIn["Cookie"] as string | undefined) || ""; + const parts = cookies.split(";"); + for (let i = 0; i < parts.length; i++) { + const part = parts[i].trim(); + if (part.indexOf(name + "=") === 0) { + return decodeURIComponent(part.substring(name.length + 1)); + } + } + return undefined; +} + +function _variantForHost(env: string, host: string | undefined): string | undefined { + if (!host) { + return undefined; + } + const variants = OS_HOST_VARIANTS[env] || {}; + const names = Object.keys(variants); + for (let i = 0; i < names.length; i++) { + if (variants[names[i]] === host.toLowerCase()) { + return names[i]; + } + } + return undefined; +} + +function _hostOf(url: string | undefined): string | undefined { + const match = (url || "").match(/^https?:\/\/([^/:]+)/i); + return match ? match[1].toLowerCase() : undefined; +} + +// js_set for the config.json locations: which blob to serve. +// - Cross-origin (the component or the handover on an OS page, which sends +// Origin): the config for THAT page's host, whatever the switch says. MSAL +// tokens, OS ClientVars and CMS auth are all per-origin, so config has to +// match the page it runs on. +// - Same-origin (the component on CWA, which sends the cookie but no Origin): +// the variant the switch names, if any. +// - Anything else, or a host with no variant: config.json. +function readConfigBlobName(r: NginxHTTPRequest): string { + // Set by the per-environment config.json location before it hands over to + // the shared internal location, so read it rather than the (rewritten) URI. + const env = (r.variables.gloco_config_env as string | undefined) || ""; + const originHost = _hostOf(r.headersIn["Origin"] as string | undefined); + const requestHost = ((r.headersIn["Host"] as string | undefined) || "").split(":")[0].toLowerCase(); + const isCrossOrigin = !!originHost && originHost !== requestHost; + + const variant = isCrossOrigin + ? _variantForHost(env, originHost) + : _variantForHost(env, _getCookie(r, OS_TARGET_COOKIE_PREFIX + env)); + + return variant ? "config." + variant + ".json" : "config.json"; +} + +// /global-components/os-target/ — the switch, driven by the preview page. +// GET → { current: | null, options: [{ variant, host }] } +// PUT → body { host }: switch onto that variant host (must be one of options) +// DELETE → switch back to the environment's own host +// Same-origin only (the preview page is served from this host), so no CORS. +function handleOsTarget(r: NginxHTTPRequest): void { + const env = (r.uri.match(/^\/global-components\/os-target\/([^/]+)$/) || [])[1] || ""; + const variants = OS_HOST_VARIANTS[env] || {}; + const cookieName = OS_TARGET_COOKIE_PREFIX + env; + const setCookie = (value: string, expires: Date) => { + // njs types Set-Cookie as string[]; a single string is fine at runtime + // (same cast as _setCookie in main/global-components.ts). + (r.headersOut as Record)["Set-Cookie"] = + cookieName + "=" + encodeURIComponent(value) + "; Path=/; Expires=" + expires.toUTCString() + "; Secure; HttpOnly; SameSite=Lax"; + }; + r.headersOut["Content-Type"] = "application/json"; + + if (r.method === "GET") { + const current = _getCookie(r, cookieName); + r.return( + 200, + JSON.stringify({ + current: _variantForHost(env, current) ? current : null, + options: Object.keys(variants).map(variant => ({ variant, host: variants[variant] })), + }), + ); + return; + } + + if (r.method === "PUT") { + let host: string | undefined; + try { + host = JSON.parse(r.requestText || "{}").host; + } catch (e) { + host = undefined; + } + if (!_variantForHost(env, host)) { + r.return(400, JSON.stringify({ error: "unknown OS host for " + env })); + return; + } + setCookie(host!.toLowerCase(), new Date(Date.now() + OS_TARGET_COOKIE_LIFESPAN_MS)); + r.return(200, JSON.stringify({ current: host!.toLowerCase() })); + return; + } + + if (r.method === "DELETE") { + setCookie("", new Date(0)); + r.return(200, JSON.stringify({ current: null })); + return; + } + + r.return(405, JSON.stringify({ error: "Method not allowed" })); +} + function filterSwaggerBody( r: NginxHTTPRequest, data: string, @@ -142,4 +283,7 @@ export default { handleValidateToken, handleStatus, filterSwaggerBody, + readConfigBlobName, + handleOsTarget, + OS_HOST_VARIANTS, }; diff --git a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js index 748ff9065..a4db1ae3c 100755 --- a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js +++ b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js @@ -77,6 +77,73 @@ async function testSwaggerRewriting() { }) } +// ============================================================================= +// OS Host Variant Tests +// ============================================================================= + +async function testOsHostVariants() { + console.log("\nOS Host Variant Tests (config.json selection + os-target switch):") + + const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" + const CONFIG_URL = `${PROXY_BASE}/global-components/test/config.json` + const blobFileOf = response => response.headers.get("x-mock-blob-file") + + await test("serves config.json from blob storage when there is no switch", async () => { + const response = await fetch(CONFIG_URL) + assertEqual(response.status, 200, "Should return 200") + assertEqual(blobFileOf(response), "config.json", "Should serve the base config") + assert((response.headers.get("vary") || "").includes("Cookie"), "Should vary on Cookie") + }) + + await test("serves the variant named by the switch cookie", async () => { + const response = await fetch(CONFIG_URL, { + headers: { Cookie: `Gloco-Os-Target-test=${OAPPS}` }, + }) + assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") + }) + + await test("serves the variant matching an OS page's Origin", async () => { + const response = await fetch(CONFIG_URL, { + headers: { Origin: `https://${OAPPS}` }, + }) + assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") + assertEqual(response.headers.get("access-control-allow-origin"), "*", "Should keep CORS") + }) + + await test("leaves every other artifact to the main conf", async () => { + const response = await fetch(`${PROXY_BASE}/global-components/test/global-components.js`, { + headers: { Cookie: `Gloco-Os-Target-test=${OAPPS}` }, + }) + assertEqual(blobFileOf(response), "global-components.js", "Should be untouched") + }) + + await test("os-target: PUT sets the cookie, GET reads it back, DELETE clears it", async () => { + const put = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { + method: "PUT", + body: JSON.stringify({ host: OAPPS }), + }) + assertEqual(put.status, 200, "PUT should return 200") + const cookie = (put.headers.get("set-cookie") || "").split(";")[0] + assertEqual(cookie, `Gloco-Os-Target-test=${OAPPS}`, "Should set the switch cookie") + + const get = await fetchJson(`${PROXY_BASE}/global-components/os-target/test`, { + headers: { Cookie: cookie }, + }) + assertEqual(get.current, OAPPS, "GET should report the switch") + + const del = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { method: "DELETE" }) + assert((del.headers.get("set-cookie") || "").includes("1970"), "DELETE should expire the cookie") + }) + + await test("os-target: refuses a host that is not a variant", async () => { + const response = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { + method: "PUT", + body: JSON.stringify({ host: "evil.example.com" }), + }) + assertEqual(response.status, 400, "Should return 400") + }) +} + // ============================================================================= // Main // ============================================================================= @@ -87,6 +154,7 @@ async function main() { await testStatusEndpoint() await testSwaggerRewriting() + await testOsHostVariants() } module.exports = main diff --git a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts index 36c468647..5cf68c37a 100644 --- a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts +++ b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts @@ -50,6 +50,9 @@ interface GlocoVnextModule { handleValidateToken(r: MockRequest): Promise handleStatus(r: MockRequest): void filterSwaggerBody(r: MockRequest, data: string, flags: Record): void + readConfigBlobName(r: MockRequest): string + handleOsTarget(r: MockRequest): void + OS_HOST_VARIANTS: Record> } // Bundle both modules @@ -273,6 +276,136 @@ async function runTests(): Promise { ) }) + // --- OS host variants --- + console.log("\nreadConfigBlobName:") + + const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" + const configRequest = (headersIn: Record, env = "test") => + createMockRequest({ + uri: "/global-components/__config", + headersIn: { Host: "polaris-qa-notprod.cps.gov.uk", ...headersIn }, + variables: { gloco_config_env: env }, + }) + + await test("serves config.json when there is no switch", async () => { + assertEqual(glocovnext.readConfigBlobName(configRequest({})), "config.json", "blob") + }) + + await test("serves the variant the switch cookie names (CWA, same-origin)", async () => { + const r = configRequest({ Cookie: `other=1; Gloco-Os-Target-test=${OAPPS}` }) + assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") + }) + + await test("treats an Origin of our own host as same-origin, so the cookie still decides", async () => { + const r = configRequest({ Origin: "https://polaris-qa-notprod.cps.gov.uk", Cookie: `Gloco-Os-Target-test=${OAPPS}` }) + assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") + }) + + await test("serves the variant for the OS page's own host, whatever the cookie says", async () => { + const r = configRequest({ Origin: `https://${OAPPS}` }) + assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") + }) + + // Config has to match the page it runs on: auth state is per-origin. + await test("serves config.json to the environment's own OS host even when switched", async () => { + const r = configRequest({ Origin: "https://cps-tst.outsystemsenterprise.com", Cookie: `Gloco-Os-Target-test=${OAPPS}` }) + assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") + }) + + await test("ignores a switch for another environment", async () => { + const r = configRequest({ Cookie: `Gloco-Os-Target-test=${OAPPS}` }, "dev") + assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") + }) + + await test("ignores a switch naming a host with no variant", async () => { + const r = configRequest({ Cookie: "Gloco-Os-Target-test=evil.example.com" }) + assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") + }) + + console.log("\nhandleOsTarget:") + + const osTargetRequest = (method: string, options: MockRequestOptions = {}) => + createMockRequest({ method, uri: "/global-components/os-target/test", ...options }) + + await test("GET lists the environment's variants and the current switch", async () => { + const r = osTargetRequest("GET", { headersIn: { Cookie: `Gloco-Os-Target-test=${OAPPS}` } }) + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 200, "status") + assertEqual( + r.returnBody, + JSON.stringify({ + current: OAPPS, + options: [ + { variant: "oapps", host: OAPPS }, + { variant: "cps-lon", host: "cpslon-tst.outsystemsenterprise.com" }, + ], + }), + "body", + ) + }) + + await test("GET reports no switch for a stale host", async () => { + const r = osTargetRequest("GET", { headersIn: { Cookie: "Gloco-Os-Target-test=gone.example.com" } }) + glocovnext.handleOsTarget(r) + assertEqual(JSON.parse(r.returnBody!).current, null, "current") + }) + + await test("PUT sets a Path=/ cookie for a known host", async () => { + const r = osTargetRequest("PUT", { requestText: JSON.stringify({ host: OAPPS }) }) + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 200, "status") + const cookie = r.headersOut["Set-Cookie"] as string + assert(cookie.startsWith(`Gloco-Os-Target-test=${OAPPS}; Path=/;`), `cookie: ${cookie}`) + assert(cookie.includes("Secure") && cookie.includes("HttpOnly") && cookie.includes("SameSite=Lax"), `cookie: ${cookie}`) + }) + + await test("PUT refuses a host that is not a variant of this environment", async () => { + for (const requestText of [JSON.stringify({ host: "evil.example.com" }), "not json", ""]) { + const r = osTargetRequest("PUT", { requestText }) + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 400, `status for ${requestText}`) + assertEqual(r.headersOut["Set-Cookie"], undefined, "no cookie") + } + }) + + await test("PUT refuses for an environment with no variants", async () => { + const r = createMockRequest({ method: "PUT", uri: "/global-components/os-target/prod", requestText: JSON.stringify({ host: OAPPS }) }) + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 400, "status") + }) + + await test("DELETE expires the cookie", async () => { + const r = osTargetRequest("DELETE") + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 200, "status") + assert((r.headersOut["Set-Cookie"] as string).includes("Expires=Thu, 01 Jan 1970"), "expired") + }) + + await test("other methods are refused", async () => { + const r = osTargetRequest("POST") + glocovnext.handleOsTarget(r) + assertEqual(r.returnCode, 405, "status") + }) + + // The table and the files are two halves of one fact: a variant the proxy + // offers must have a config to serve, and a config nobody can reach is dead. + await test("OS_HOST_VARIANTS matches configuration/config...json both ways", async () => { + const configDir = path.join(__dirname, "..", "..", "..", "..", "..", "configuration") + const files = fs + .readdirSync(configDir) + .map((file: string) => file.match(/^config\.([^.]+)\.([^.]+)\.json$/)) + .filter((m: RegExpMatchArray | null): m is RegExpMatchArray => !!m && m[2] !== "notification") + const fromFiles = files.map(([file, env, variant]: RegExpMatchArray) => { + const text = originalReadFileSync.call(fs, path.join(configDir, file), "utf8") as string + const host = (text.match(/"BANNER_TITLE_HREF": "https:\/\/([^/]+)\//) || [])[1] + return `${env}/${variant}=${host}` + }) + const fromTable = Object.entries(glocovnext.OS_HOST_VARIANTS).flatMap(([env, variants]) => + Object.entries(variants).map(([variant, host]) => `${env}/${variant}=${host}`), + ) + assertEqual(JSON.stringify(fromTable.sort()), JSON.stringify(fromFiles.sort()), "variants") + }) + // Summary console.log("\n" + "=".repeat(60)) console.log(`Results: ${passed} passed, ${failed} failed`) diff --git a/infra/proxy/config/main/global-components.ts b/infra/proxy/config/main/global-components.ts index 44fb1b6c0..13dd7be34 100644 --- a/infra/proxy/config/main/global-components.ts +++ b/infra/proxy/config/main/global-components.ts @@ -7,6 +7,10 @@ const CORS_ALLOWED_ORIGINS = [ "https://cps-tst.outsystemsenterprise.com", "https://cps-tst1.outsystemsenterprise.com", "https://cps-dev.outsystemsenterprise.com", + // London test OS host — the cps-lon variant of test (see OS_HOST_VARIANTS in + // global-components.vnext.ts). The oapps-*.int.cps.gov.uk variants need no entry: + // the .cps.gov.uk suffix check below already admits them. + "https://cpslon-tst.outsystemsenterprise.com", "http://localhost", "https://localhost", "http://127.0.0.1", diff --git a/infra/proxy/config/main/nginx.js b/infra/proxy/config/main/nginx.js index 5d533863c..383de9b37 100644 --- a/infra/proxy/config/main/nginx.js +++ b/infra/proxy/config/main/nginx.js @@ -146,6 +146,67 @@ function _shimBigIpCookies(args) { return clonedArgs } +// OS host switch (global-components multi-targeting). Named users can be switched +// onto another OutSystems host than the rest of their environment (e.g. the oapps +// proxy, or the London tenant) via a per-environment cookie on this host, set from +// the global-components preview page: +// Gloco-Os-Target-= +// Every route into OutSystems — the C-button, the menu's outbound links, the +// case-review redirect — reaches here with r pointing at the OS auth handover, so +// this is the one place that moves the user onto the switched host. It runs before +// anything is written to OS-origin storage, which is per-host. +// +// The environment comes from the handover's own src param +// (.../global-components//auth-handover.js): dev and test share this host, so +// the cookie has to be per environment. Both the handover host and the host of its +// final destination (the nested r) are swapped, as the handover only returns users +// to its own origin. +// +// Returns the rewritten URL, or the original one when there's no switch or the URL +// isn't an OS handover. The caller only uses a rewrite that AUTH_HANDOVER_WHITELIST +// accepts, so a stale or tampered cookie falls back to the original target rather +// than a 403. +const OS_TARGET_COOKIE_PREFIX = "Gloco-Os-Target-" + +function _escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function _toOsTarget(r, url) { + const match = /^(https:\/\/)([^/?#]+)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(url || "") + if (!match) { + return url + } + // Indexed rather than destructured: njs has no destructuring assignment. + const scheme = match[1] + const fromHost = match[2] + const path = match[3] + const query = match[4] || "" + const hash = match[5] || "" + if (!/\/auth-handover\.html$/i.test(path)) { + return url + } + + const src = qs.parse(query.replace(/^\?/, ""))["src"] || "" + const env = (/\/global-components\/([^/]+)\/auth-handover\.js/.exec(src) || [])[1] + if (!env) { + return url + } + + const toHost = _maybeDecodeURIComponent(_getCookieValue(r, OS_TARGET_COOKIE_PREFIX + env)).toLowerCase() + if (!/^[a-z0-9.-]+$/.test(toHost) || toHost === fromHost.toLowerCase()) { + return url + } + + // The nested final destination, in the r param: percent-encoded when it came + // from a URL builder, plain when hand-written. + const nestedDestination = new RegExp( + "([?&]r=https(?:%3A%2F%2F|://))" + _escapeRegExp(fromHost) + "(?=%2F|%3F|%23|/|&|$)", + "i" + ) + return scheme + toHost + path + query.replace(nestedDestination, "$1" + toHost) + hash +} + function appAuthRedirect(r) { setSessionHintCookie(r) @@ -153,10 +214,16 @@ function appAuthRedirect(r) { args = _shimBigIpCookies(args) const whitelistedUrls = process.env.AUTH_HANDOVER_WHITELIST ?? "" - const redirectUrl = args["r"] - const isWhitelisted = whitelistedUrls - .split(",") - .some((url) => redirectUrl.startsWith(url)) + const isWhitelistedUrl = (url) => + whitelistedUrls.split(",").some((prefix) => url.startsWith(prefix)) + + const requestedUrl = args["r"] + const osTargetUrl = _toOsTarget(r, requestedUrl) + const redirectUrl = + osTargetUrl !== requestedUrl && isWhitelistedUrl(osTargetUrl) + ? osTargetUrl + : requestedUrl + const isWhitelisted = isWhitelistedUrl(redirectUrl) if (isWhitelisted) { _redirectToAbsoluteUrl( diff --git a/infra/proxy/config/main/tests/global-components.unit.test.ts b/infra/proxy/config/main/tests/global-components.unit.test.ts index c643e97d4..b73125e8f 100644 --- a/infra/proxy/config/main/tests/global-components.unit.test.ts +++ b/infra/proxy/config/main/tests/global-components.unit.test.ts @@ -192,6 +192,28 @@ async function runTests(): Promise { assertEqual(gloco.readCorsOrigin(r), "", "Should return empty string") }) + await test("returns origin for the London test OS host", async () => { + const r = createMockRequest({ + headersIn: { Origin: "https://cpslon-tst.outsystemsenterprise.com" }, + }) + assertEqual( + gloco.readCorsOrigin(r), + "https://cpslon-tst.outsystemsenterprise.com", + "Should return origin", + ) + }) + + await test("returns origin for an oapps proxy host via the .cps.gov.uk rule", async () => { + const r = createMockRequest({ + headersIn: { Origin: "https://oapps-qa-notprod.int.cps.gov.uk" }, + }) + assertEqual( + gloco.readCorsOrigin(r), + "https://oapps-qa-notprod.int.cps.gov.uk", + "Should return origin", + ) + }) + await test("returns origin for a .cps.gov.uk subdomain", async () => { const r = createMockRequest({ headersIn: { Origin: "https://foo.cps.gov.uk" }, diff --git a/infra/proxy/config/main/tests/nginx.unit.test.ts b/infra/proxy/config/main/tests/nginx.unit.test.ts index 3f477abdb..8f753b0ba 100644 --- a/infra/proxy/config/main/tests/nginx.unit.test.ts +++ b/infra/proxy/config/main/tests/nginx.unit.test.ts @@ -180,6 +180,99 @@ async function runTests(): Promise { ) }) + // --- OS host switch (Gloco-Os-Target-) --- + const TEST_HANDOVER = + "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" + + "?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js" + + "&stage=os-cookie-return" + + "&r=https%3A%2F%2Fcps-tst.outsystemsenterprise.com%2Fcasework_blocks%2Fhome%3FIsFromCMS%3DTrue" + const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" + const BOTH_HANDOVERS = + "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html," + + `https://${OAPPS}/Casework_Patterns/auth-handover.html` + + const initRequest = (r: string, cookieHeader?: string) => + createMockRequest({ + args: { r, cookie: "session=abc123" }, + headersIn: { + "X-Forwarded-Proto": "https", + Host: "polaris-qa-notprod.cps.gov.uk", + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + }, + }) + + await test("OS switch: leaves the handover alone when there is no switch cookie", async () => { + process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS + const r = initRequest(TEST_HANDOVER) + nginx.appAuthRedirect(r) + assertEqual(r.returnCode, 302, "Should redirect") + assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) + }) + + await test("OS switch: moves the handover and its destination onto the switched host", async () => { + process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS + const r = initRequest(TEST_HANDOVER, `other=1; Gloco-Os-Target-test=${OAPPS}`) + nginx.appAuthRedirect(r) + assertEqual(r.returnCode, 302, "Should redirect") + assert( + r.returnBody!.startsWith(`https://${OAPPS}/Casework_Patterns/auth-handover.html?`), + `Should target the switched host's handover, got: ${r.returnBody}` + ) + assert( + r.returnBody!.includes(`&r=https%3A%2F%2F${OAPPS}%2Fcasework_blocks%2Fhome`), + `Should move the nested destination too, got: ${r.returnBody}` + ) + assert( + r.returnBody!.includes("src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk"), + `Should leave the polaris src alone, got: ${r.returnBody}` + ) + assert(r.returnBody!.includes("cc=session%3Dabc123"), `Should still append cc, got: ${r.returnBody}`) + }) + + await test("OS switch: also moves a plain (unencoded) nested destination", async () => { + process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS + const handover = + "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" + + "?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js" + + "&stage=os-cookie-return&r=https://cps-tst.outsystemsenterprise.com/CaseReview/LandingPage" + const r = initRequest(handover, `Gloco-Os-Target-test=${OAPPS}`) + nginx.appAuthRedirect(r) + assert( + r.returnBody!.includes(`&r=https://${OAPPS}/CaseReview/LandingPage`), + `Should move the nested destination, got: ${r.returnBody}` + ) + }) + + await test("OS switch: falls back to the original handover when the switched one isn't whitelisted", async () => { + process.env.AUTH_HANDOVER_WHITELIST = "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" + const r = initRequest(TEST_HANDOVER, "Gloco-Os-Target-test=stale.example.com") + nginx.appAuthRedirect(r) + assertEqual(r.returnCode, 302, "Should still redirect, not 403") + assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) + }) + + await test("OS switch: ignores a switch for another environment", async () => { + process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS + const r = initRequest(TEST_HANDOVER, `Gloco-Os-Target-dev=${OAPPS}`) + nginx.appAuthRedirect(r) + assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) + }) + + await test("OS switch: ignores targets that are not an OS handover", async () => { + process.env.AUTH_HANDOVER_WHITELIST = "/auth-refresh-inbound" + const r = initRequest("/auth-refresh-inbound", `Gloco-Os-Target-test=${OAPPS}`) + nginx.appAuthRedirect(r) + assertEqual(r.returnCode, 302, "Should redirect") + assert(r.returnBody!.includes("/auth-refresh-inbound?cc="), `Should be unchanged, got: ${r.returnBody}`) + }) + + await test("OS switch: ignores a cookie value that isn't a hostname", async () => { + process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS + const r = initRequest(TEST_HANDOVER, "Gloco-Os-Target-test=evil.example.com%2Fpath") + nginx.appAuthRedirect(r) + assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) + }) + await test("sets Cms-Session-Hint cookie with correct attributes", async () => { process.env.AUTH_HANDOVER_WHITELIST = "/auth-refresh-inbound" const r = createMockRequest({ diff --git a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts index c9ae3d8cd..95b64d951 100644 --- a/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts +++ b/packages/cps-global-components/src/services/application-flags/is-outsystems-app.spec.ts @@ -23,7 +23,7 @@ describe("isOutSystemsApp", () => { }); it("should return true for the oapps proxies OutSystems is moving behind", () => { - expect(isOutSystemsApp(createMockWindow("https://oapps.cps.gov.uk/Casework/home"))).toBe(true); + expect(isOutSystemsApp(createMockWindow("https://oapps.int.cps.gov.uk/Casework/home"))).toBe(true); expect(isOutSystemsApp(createMockWindow("https://oapps-qa-notprod.int.cps.gov.uk/WorkManagementApp/TaskList"))).toBe(true); expect(isOutSystemsApp(createMockWindow("https://polaris-qa-notprod.cps.gov.uk/polaris-ui"))).toBe(false); }); diff --git a/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts b/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts index 2c93f381c..ce814ddca 100644 --- a/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts +++ b/packages/cps-global-configuration/src/csp/derive-check-targets.spec.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { deriveAllCheckTargets, deriveCheckTargets } from "./derive-check-targets"; +import { listOsHostVariantFiles } from "../scripts/os-host-variant-files"; const CONFIG_DIR = join(__dirname, "..", "..", "..", "..", "configuration"); const ENVIRONMENTS = ["dev", "test", "uat", "prod"]; @@ -57,6 +58,35 @@ describe("deriveCheckTargets", () => { }); }); +describe("against the committed OS host variants", () => { + const targets = deriveAllCheckTargets( + Object.fromEntries( + listOsHostVariantFiles(CONFIG_DIR).map(({ file, env, variant }) => [ + `${env}.${variant}`, + JSON.parse(readFileSync(join(CONFIG_DIR, file), "utf8")), + ]), + ), + ); + + // QA's alternative hosts configure their CSP independently of cps-tst, so the + // live checker probes them as well. + it("probes the oapps and London hosts in QA", () => { + expect(new Set(targets.map(t => `${t.environment} ${new URL(t.url).hostname}`))).toEqual( + new Set([ + "test.oapps oapps-qa-notprod.int.cps.gov.uk", + "test.cps-lon cpslon-tst.outsystemsenterprise.com", + ]), + ); + }); + + it("includes each variant's handover page", () => { + expect(targets.filter(t => t.kind === "auth-handover").map(t => t.environment).sort()).toEqual([ + "test.cps-lon", + "test.oapps", + ]); + }); +}); + describe("against the committed configs", () => { const targets = deriveAllCheckTargets( Object.fromEntries(ENVIRONMENTS.map(env => [env, loadConfig(env)])), diff --git a/packages/cps-global-configuration/src/csp/derive-csp.spec.ts b/packages/cps-global-configuration/src/csp/derive-csp.spec.ts index 43fa2b79a..9a8b7016b 100644 --- a/packages/cps-global-configuration/src/csp/derive-csp.spec.ts +++ b/packages/cps-global-configuration/src/csp/derive-csp.spec.ts @@ -234,8 +234,11 @@ describe("the committed environment configs", () => { // Fails when someone adds configuration/config..json without // deciding whether it belongs in ENVIRONMENTS above. const known = new Set([...configFiles, "config.accessibility.json"]); + // OS host variants (config...json) are deliberately absent: + // outsystems-host-consistency.spec.ts pins each one to its environment's + // config with only the OS host swapped, and nothing here depends on that host. const unaccountedFor = readdirSync(CONFIG_DIR).filter( - f => /^config\..+\.json$/.test(f) && !f.includes("notification") && !known.has(f), + f => /^config\.[^.]+\.json$/.test(f) && !known.has(f), ); expect(unaccountedFor).toEqual([]); diff --git a/packages/cps-global-configuration/src/is-outsystems-host.test.ts b/packages/cps-global-configuration/src/is-outsystems-host.test.ts index 04b391bf4..12d69cf5a 100644 --- a/packages/cps-global-configuration/src/is-outsystems-host.test.ts +++ b/packages/cps-global-configuration/src/is-outsystems-host.test.ts @@ -5,7 +5,7 @@ describe("isOutSystemsHostname", () => { "cps.outsystemsenterprise.com", "cps-tst.outsystemsenterprise.com", "CPS-TST1.outsystemsenterprise.com", - "oapps.cps.gov.uk", + "oapps.int.cps.gov.uk", "oapps-qa-notprod.int.cps.gov.uk", "oapps-dev-notprod.int.cps.gov.uk", ])("recognises %s", hostname => { @@ -18,7 +18,10 @@ describe("isOutSystemsHostname", () => { // Only the proxy hosts themselves, not anything else under cps.gov.uk that // happens to start with oapps. "oapps-evil.cps.gov.uk", - "oapps.cps.gov.uk.example.com", + "oapps.int.cps.gov.uk.example.com", + // The proxies all live under int.cps.gov.uk. + "oapps.cps.gov.uk", + "oapps-qa-notprod.cps.gov.uk", "outsystemsenterprise.com.example.com", "notoutsystemsenterprise.com", ])("rejects %s", hostname => { diff --git a/packages/cps-global-configuration/src/is-outsystems-host.ts b/packages/cps-global-configuration/src/is-outsystems-host.ts index 1e6d6fa1d..434f28694 100644 --- a/packages/cps-global-configuration/src/is-outsystems-host.ts +++ b/packages/cps-global-configuration/src/is-outsystems-host.ts @@ -1,9 +1,9 @@ // Which hosts are OutSystems. Knows both the outsystemsenterprise.com tenants -// and the CPS reverse proxies OutSystems is moving behind (oapps.cps.gov.uk in -// prod, oapps--notprod.int.cps.gov.uk elsewhere), so recognising OS needs +// and the CPS reverse proxies OutSystems is moving behind (oapps.int.cps.gov.uk +// in prod, oapps--notprod.int.cps.gov.uk elsewhere), so recognising OS needs // no change when an environment moves across. It only classifies a host — which // host an environment actually uses is whatever its config's URLs say. -const OUTSYSTEMS_HOSTNAME = /\.outsystemsenterprise\.com$|^oapps(-[a-z0-9]+-notprod\.int)?\.cps\.gov\.uk$/i; +const OUTSYSTEMS_HOSTNAME = /\.outsystemsenterprise\.com$|^oapps(-[a-z0-9]+-notprod)?\.int\.cps\.gov\.uk$/i; export const isOutSystemsHostname = (hostname: string): boolean => OUTSYSTEMS_HOSTNAME.test(hostname); diff --git a/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts b/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts index a5dd74b0f..bb32a510d 100644 --- a/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts +++ b/packages/cps-global-configuration/src/outsystems-host-consistency.spec.ts @@ -1,17 +1,25 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { isOutSystemsHostname } from "./is-outsystems-host"; +import { listOsHostVariantFiles } from "./scripts/os-host-variant-files"; /** - * One OutSystems host per environment, written out literally in each config. - * Moving an environment to another OS host (e.g. onto the oapps proxies) is a - * find-and-replace across its config; this is what catches a URL or context - * path that got missed — or a second host sneaking back in. + * One OutSystems host per config file, written out literally. Moving an + * environment to another OS host is a find-and-replace across its config; this + * is what catches a URL or context path that got missed — or a second host + * sneaking back in. + * + * An environment may also have OS host variants (config...json), + * served by the proxy to users switched onto another OS host. A variant must be + * its environment's config with the host swapped and nothing else, so the two + * can never drift apart. */ const CONFIG_DIR = join(__dirname, "..", "..", "..", "configuration"); const ENVIRONMENTS = ["dev", "test", "uat", "prod"]; +const VARIANTS = listOsHostVariantFiles(CONFIG_DIR); + type Found = { urlHosts: string[]; pathHosts: string[] }; const collect = (node: unknown, found: Found, key?: string): Found => { @@ -38,11 +46,12 @@ const collect = (node: unknown, found: Found, key?: string): Found => { return found; }; -describe.each(ENVIRONMENTS)("config.%s.json", env => { - const { urlHosts, pathHosts } = collect( - JSON.parse(readFileSync(join(CONFIG_DIR, `config.${env}.json`), "utf8")), - { urlHosts: [], pathHosts: [] }, - ); +const load = (file: string): unknown => JSON.parse(readFileSync(join(CONFIG_DIR, file), "utf8")); + +const osHostOf = (file: string): string => collect(load(file), { urlHosts: [], pathHosts: [] }).urlHosts[0]!; + +describe.each([...ENVIRONMENTS.map(env => `config.${env}.json`), ...VARIANTS.map(({ file }) => file)])("%s", file => { + const { urlHosts, pathHosts } = collect(load(file), { urlHosts: [], pathHosts: [] }); const [host] = urlHosts; it("names exactly one OutSystems host across its URLs", () => { @@ -54,3 +63,17 @@ describe.each(ENVIRONMENTS)("config.%s.json", env => { expect(new Set(pathHosts)).toEqual(new Set([host])); }); }); + +describe.each(VARIANTS)("$file", ({ file, env }) => { + it(`is config.${env}.json with only the OS host swapped`, () => { + const baseFile = `config.${env}.json`; + const [from, to] = [osHostOf(baseFile), osHostOf(file)]; + // Context paths hold the host regex-escaped, which in the raw JSON text is + // a doubled backslash before each dot. + const escape = (host: string) => host.replace(/\./g, "\\\\."); + const swapped = readFileSync(join(CONFIG_DIR, baseFile), "utf8").split(from).join(to).split(escape(from)).join(escape(to)); + + expect(from).not.toBe(to); + expect(load(file)).toEqual(JSON.parse(swapped)); + }); +}); diff --git a/packages/cps-global-configuration/src/scripts/check-csp.ts b/packages/cps-global-configuration/src/scripts/check-csp.ts index 6f55e7fa4..afd83d2a5 100644 --- a/packages/cps-global-configuration/src/scripts/check-csp.ts +++ b/packages/cps-global-configuration/src/scripts/check-csp.ts @@ -32,6 +32,7 @@ import { type CheckTarget, } from "../csp/derive-check-targets"; import { CSP_ENVIRONMENTS } from "../csp/render-csp-artifacts"; +import { listOsHostVariantFiles } from "./os-host-variant-files"; import { renderHtmlReport, renderMarkdownReport, @@ -153,14 +154,29 @@ const main = async (): Promise => { CSP_ENVIRONMENTS.map(env => configs[env]!), ); - const targets = deriveAllCheckTargets(configs); + // OS host variants (e.g. test.oapps, test.cps-lon) are the same app on + // another OutSystems host, which configures its CSP independently — so probe + // them too, judged against their own (host-swapped) config. + const checkedConfigs: Record = { + ...configs, + ...Object.fromEntries( + listOsHostVariantFiles(configDir) + .filter(({ env }) => CSP_ENVIRONMENTS.includes(env)) + .map(({ file, env, variant }) => [ + `${env}.${variant}`, + JSON.parse(fs.readFileSync(path.join(configDir, file), "utf-8")), + ]), + ), + }; + + const targets = deriveAllCheckTargets(checkedConfigs); console.log(`checking ${targets.length} targets…`); const fetched = await Promise.all(targets.map(fetchTarget)); const results: TargetResult[] = fetched.map(raw => { const { hostApp, handoverPage } = deriveCspRequirements( - configs[raw.target.environment]!, + checkedConfigs[raw.target.environment]!, ); const isHandover = raw.target.kind === "auth-handover"; const body = (raw as TargetResult & { body?: string }).body; diff --git a/packages/cps-global-configuration/src/scripts/os-host-variant-files.ts b/packages/cps-global-configuration/src/scripts/os-host-variant-files.ts new file mode 100644 index 000000000..fde045088 --- /dev/null +++ b/packages/cps-global-configuration/src/scripts/os-host-variant-files.ts @@ -0,0 +1,14 @@ +import * as fs from "node:fs"; + +// OS host variants: configuration/config...json is 's config +// pointed at another OutSystems host (see outsystems-host-consistency.spec.ts, and +// OS_HOST_VARIANTS in infra/proxy/config/global-components.vnext). Notification +// files share the filename shape, so they're excluded. +export type OsHostVariantFile = { file: string; env: string; variant: string }; + +export const listOsHostVariantFiles = (configDir: string): OsHostVariantFile[] => + fs + .readdirSync(configDir) + .map(file => file.match(/^config\.([^.]+)\.([^.]+)\.json$/)) + .filter((match): match is RegExpMatchArray => !!match && match[2] !== "notification") + .map(([file, env, variant]) => ({ file, env: env!, variant: variant! })); diff --git a/packages/cps-global-configuration/src/validator.test.ts b/packages/cps-global-configuration/src/validator.test.ts new file mode 100644 index 000000000..0f12d72c6 --- /dev/null +++ b/packages/cps-global-configuration/src/validator.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { transformAndValidateConfig } from "./validator"; + +const CONFIG_DIR = join(__dirname, "..", "..", "..", "configuration"); +const load = (filename: string) => JSON.parse(readFileSync(join(CONFIG_DIR, filename), "utf8")); + +describe("transformAndValidateConfig filename check", () => { + it("accepts an OS host variant carrying its environment's ENVIRONMENT", () => { + expect(transformAndValidateConfig(load("config.test.oapps.json"), "config.test.oapps.json").success).toBe(true); + }); + + it.each(["config.uat.json", "config.uat.oapps.json"])("rejects %s when ENVIRONMENT names another environment", filename => { + const result = transformAndValidateConfig(load("config.test.json"), filename); + + expect(result).toEqual({ success: false, errorMsg: expect.stringContaining('does not match filename environment "uat"') }); + }); +}); diff --git a/packages/cps-global-configuration/src/validator.ts b/packages/cps-global-configuration/src/validator.ts index 26b8c45e9..8d49177c5 100644 --- a/packages/cps-global-configuration/src/validator.ts +++ b/packages/cps-global-configuration/src/validator.ts @@ -55,7 +55,10 @@ const validateStoredEnvironment = ( return; } - const match = filename.match(/^config\.(.+)\.json$/); + // config..json, or config...json for an OS host variant of + // that environment (e.g. config.test.oapps.json) — the variant keeps its + // environment's ENVIRONMENT value. + const match = filename.match(/^config\.([^.]+)(?:\.[^.]+)?\.json$/); if (!match) { return; } diff --git a/packages/cps-global-preview/src/App.tsx b/packages/cps-global-preview/src/App.tsx index 32ea5a328..deb237deb 100644 --- a/packages/cps-global-preview/src/App.tsx +++ b/packages/cps-global-preview/src/App.tsx @@ -7,6 +7,15 @@ const AUTH_HINT_ENDPOINT = "/global-components/state/auth-hint"; const ENV_MATCH = window.location.pathname.match(/\/global-components\/([^/]+)\//); const ENV = ENV_MATCH?.[1] ?? "test"; const NOTIFICATIONS_ENDPOINT = `/global-components/${ENV}/notification.json`; +// The per-environment OS host switch, owned by the proxy (see OS_HOST_VARIANTS +// in infra/proxy/config/global-components.vnext). It lives in its own cookie +// rather than preview state because Polaris's /init has to read it too. +const OS_TARGET_ENDPOINT = `/global-components/os-target/${ENV}`; + +type OsTarget = { + current: string | null; + options: { variant: string; host: string }[]; +}; type NotificationsResult = | { loaded: true; notifications: Notification[] } @@ -162,6 +171,7 @@ export function App() { useState(null); const [authHint, setAuthHint] = useState(null); const [sidInput, setSidInput] = useState(""); + const [osTarget, setOsTarget] = useState(null); const showStatus = useCallback((message: string, type: StatusType) => { setStatus({ message, type }); @@ -245,6 +255,39 @@ export function App() { loadNotifications(); }, [loadNotifications]); + const loadOsTarget = useCallback(async () => { + try { + const response = await fetch(OS_TARGET_ENDPOINT, { credentials: "include" }); + if (response.ok) { + setOsTarget(await response.json()); + } + } catch { + // No switch endpoint (e.g. an older proxy) — the section stays hidden. + } + }, []); + + useEffect(() => { + loadOsTarget(); + }, [loadOsTarget]); + + const handleOsTargetChange = async (host: string) => { + try { + const response = await fetch(OS_TARGET_ENDPOINT, { + method: host ? "PUT" : "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: host ? JSON.stringify({ host }) : undefined, + }); + if (!response.ok) { + throw new Error("Failed to switch OutSystems host"); + } + setOsTarget(osTarget && { ...osTarget, current: host || null }); + showStatus("OutSystems host switched — takes effect on your next page load or C-button press", "success"); + } catch (err) { + showStatus(err instanceof Error ? err.message : "Unknown error", "error"); + } + }; + const handleEnabledChange = (checked: boolean) => { const newState = { ...state, enabled: checked || undefined }; setState(newState); @@ -775,6 +818,39 @@ export function App() {
+ {osTarget && osTarget.options.length > 0 && ( +
+
+ +

OutSystems host

+
+

+ Which OutSystems host the C-button and the menu send you to in this + environment. Pages you open directly on either host keep working as + they are. +

+
+ {[{ variant: "", host: "" }, ...osTarget.options].map(({ variant, host }) => ( +
+ handleOsTargetChange(host)} + /> + +
+ ))} +
+
+
+ )} +
diff --git a/scripts/config-sync-check.sh b/scripts/config-sync-check.sh index 06bbfe642..318dfa5b5 100755 --- a/scripts/config-sync-check.sh +++ b/scripts/config-sync-check.sh @@ -92,6 +92,10 @@ for local_file in "${config_files[@]}"; do if [[ $filename =~ ^config\.([^.]+)\.json$ ]]; then env_name="${BASH_REMATCH[1]}" remote_filename="config.json" + elif [[ $filename =~ ^config\.([^.]+)\.([^.]+)\.json$ ]] && [ "${BASH_REMATCH[2]}" != "notification" ]; then + # OS host variant — config.{env}.{variant}.json → {env}/config.{variant}.json + env_name="${BASH_REMATCH[1]}" + remote_filename="config.${BASH_REMATCH[2]}.json" else echo -e "${YELLOW}⚠ Skipping $filename (doesn't match expected pattern)${NC}" continue diff --git a/scripts/config-sync.sh b/scripts/config-sync.sh index 5c12e830d..dd8de4d58 100755 --- a/scripts/config-sync.sh +++ b/scripts/config-sync.sh @@ -93,6 +93,10 @@ for local_file in "${config_files[@]}"; do if [[ $filename =~ ^config\.([^.]+)\.json$ ]]; then env_name="${BASH_REMATCH[1]}" remote_filename="config.json" + elif [[ $filename =~ ^config\.([^.]+)\.([^.]+)\.json$ ]] && [ "${BASH_REMATCH[2]}" != "notification" ]; then + # OS host variant — config.{env}.{variant}.json → {env}/config.{variant}.json + env_name="${BASH_REMATCH[1]}" + remote_filename="config.${BASH_REMATCH[2]}.json" else echo -e "${YELLOW}⚠ Skipping $filename (doesn't match expected pattern)${NC}" continue From ac217f2317d08cc257c2419fad2b1f7a50826f80 Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Thu, 24 Sep 2026 15:46:35 +0100 Subject: [PATCH 3/8] #FCT2-22132 - cookie-based approach to OS oapps/dublin/london switching --- ...nitialise-request-observation-shim.spec.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.spec.ts b/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.spec.ts index 66448c9ea..51af0ac5c 100644 --- a/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.spec.ts +++ b/packages/cps-global-components/src/services/request-observation/initialise-request-observation-shim.spec.ts @@ -121,6 +121,21 @@ describe("initialiseRequestObservationShim", () => { expect(FakeXHR.prototype.send).not.toBe(original); }); + // OutSystems is moving onto the oapps proxies; activation must not depend on + // the host, or capture goes silently dead again at the cutover. + it.each(["oapps-qa-notprod.int.cps.gov.uk", "oapps.int.cps.gov.uk", "cpslon-tst.outsystemsenterprise.com"])( + "patches on the Triage page served from %s", + host => { + const { FakeXHR } = makeFakeXHR(); + const window = makeFakeWindow(`https://${host}/WorkManagementApp/Triage?CaseId=1`, "?CaseId=1", FakeXHR); + const original = FakeXHR.prototype.send; + + initialiseRequestObservationShim({ window, config: configOn, preview: previewOn, trackEvent: jest.fn() }); + + expect(FakeXHR.prototype.send).not.toBe(original); + }, + ); + it("does not throw and leaves the prototype intact when XHR.prototype is locked", () => { const { FakeXHR } = makeFakeXHR(); const originalOpen = FakeXHR.prototype.open; @@ -329,6 +344,22 @@ describe("initialiseRequestObservationShim", () => { expect(event.SelectedCPSDirectDecision).toBe(3); }); + it("captures a submission on an oapps-hosted Triage page", () => { + const host = "https://oapps-qa-notprod.int.cps.gov.uk"; + const trackEvent = jest.fn(); + const { FakeXHR } = makeFakeXHR(); + const window = makeFakeWindow(`${host}/WorkManagementApp/Triage${odpcdSearch}`, odpcdSearch, FakeXHR); + initialiseRequestObservationShim({ window, config: configOn, preview: previewOn, trackEvent }); + + const xhr: any = new FakeXHR(); + xhr.open("POST", `${host}/WorkManagementApp/screenservices/CaseMilestone_CW/Triage/CheckDetails/ActionCompleteTriageTask`); + xhr.send(triageTaskBody({ TriageType: "ODPCDReview", SelectedCPSDirectDecision: 2 })); + + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ name: "triage-submission", TriageType: "ODPCDReview", IsCPSD: false, SelectedCPSDirectDecision: 2 }), + ); + }); + it("never emits the credential-bearing body fields", () => { const { trackEvent } = submit(odpcdSearch, triageTaskBody({ TriageType: "ODPCDReview", SelectedCPSDirectDecision: 1 })); From 04f8a5a2e0bd1c3d146d11a0869d26a839256944 Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Mon, 28 Sep 2026 18:23:49 +0100 Subject: [PATCH 4/8] #FCT2-22132 - rebuilt package type definitions --- packages/cps-global-components/src/components.d.ts | 2 +- .../src/components/cps-gds-notification-banner/readme.md | 7 +++++++ .../src/components/cps-global-locking-region/readme.md | 7 +++++++ .../components/cps-global-pinned-notification/readme.md | 7 +++++++ .../src/components/cps-global-recent-cases/readme.md | 8 ++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/cps-global-components/src/components.d.ts b/packages/cps-global-components/src/components.d.ts index a311c0820..e6146ea11 100644 --- a/packages/cps-global-components/src/components.d.ts +++ b/packages/cps-global-components/src/components.d.ts @@ -427,7 +427,7 @@ declare global { } } declare namespace LocalJSX { - type OneOf = { [P in K]: PropT } & { [P in `attr:${K}` | `prop:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K | `prop:${K}`]?: never } | { [P in `prop:${K}`]: PropT } & { [P in K | `attr:${K}`]?: never }; + type OneOf = { [P in K]: PropT } & { [P in `attr:${K}`]?: never } | { [P in `attr:${K}`]: AttrT } & { [P in K]?: never }; /** * A thin shell over govuk-frontend's notification banner. diff --git a/packages/cps-global-components/src/components/cps-gds-notification-banner/readme.md b/packages/cps-global-components/src/components/cps-gds-notification-banner/readme.md index 3e3926fcd..10fa07a01 100644 --- a/packages/cps-global-components/src/components/cps-gds-notification-banner/readme.md +++ b/packages/cps-global-components/src/components/cps-gds-notification-banner/readme.md @@ -34,6 +34,13 @@ component every notification in the app renders through. | `cpsDismissed` | Fired when the user clicks the dismiss button. | `CustomEvent` | +## Slots + +| Slot | Description | +| ---- | ---------------- | +| | The default slot | + + ## Dependencies ### Used by diff --git a/packages/cps-global-components/src/components/cps-global-locking-region/readme.md b/packages/cps-global-components/src/components/cps-global-locking-region/readme.md index ed2846c7a..530ce6147 100644 --- a/packages/cps-global-components/src/components/cps-global-locking-region/readme.md +++ b/packages/cps-global-components/src/components/cps-global-locking-region/readme.md @@ -85,6 +85,13 @@ indistinguishable from the fallback. | `subject` | `subject` | Optional subject for kinds that are scoped to one — a witness, a defendant. With it the section is ":KIND:"; without it the section is case-wide, ":KIND". Must match the id the other clients use for the same person, or the two register different sections for one subject. | `string \| undefined` | `undefined` | +## Slots + +| Slot | Description | +| ---- | ---------------- | +| | The default slot | + + ## Dependencies ### Used by diff --git a/packages/cps-global-components/src/components/cps-global-pinned-notification/readme.md b/packages/cps-global-components/src/components/cps-global-pinned-notification/readme.md index c4e909857..4f8231302 100644 --- a/packages/cps-global-components/src/components/cps-global-pinned-notification/readme.md +++ b/packages/cps-global-components/src/components/cps-global-pinned-notification/readme.md @@ -44,6 +44,13 @@ changed in years. | `cpsDismissed` | Fired when the user clicks the dismiss button. | `CustomEvent` | +## Slots + +| Slot | Description | +| ---- | ---------------- | +| | The default slot | + + ## Dependencies ### Used by diff --git a/packages/cps-global-components/src/components/cps-global-recent-cases/readme.md b/packages/cps-global-components/src/components/cps-global-recent-cases/readme.md index 36af34db4..9cd1c5f3f 100644 --- a/packages/cps-global-components/src/components/cps-global-recent-cases/readme.md +++ b/packages/cps-global-components/src/components/cps-global-recent-cases/readme.md @@ -15,6 +15,14 @@ | `listClass` | `list-class` | | `string` | `"govuk-list govuk-list--spaced govuk-list--bullet"` | +## Slots + +| Slot | Description | +| ------------ | ----------- | +| `"no-cases"` | | +| `"waiting"` | | + + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* From c8ec097ccb3f1dc728304ad6deba1ba4ee5a3bf2 Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Tue, 29 Sep 2026 10:45:41 +0100 Subject: [PATCH 5/8] #FCT2-22132 - commit TODO --- TODO.md | 339 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 189 insertions(+), 150 deletions(-) diff --git a/TODO.md b/TODO.md index 4307a5923..d24172f87 100644 --- a/TODO.md +++ b/TODO.md @@ -1,90 +1,182 @@ # TODO: OutSystems multi-targeting (QA) and the oapps cutover -Branch: `feature/FCT2-22099_CSP_rules`. +Branch: `feature/FCT2-22132_allow_oapps_os_domain_switching`. Work top to bottom: each phase +depends on the ones above it. Background and reference material are at the end. + +## 0. Tidy the branch + +- [x] Decide what to do with the uncommitted changes to `infra/proxy/config/main/cmsenv.js` and + `infra/proxy/config/main/nginx-full.conf`. They aren't part of this work. +- [x] Commit, raise the PR, and merge once CI is green (unit tests, config validation, the CSP + generated-artifact check). + +## 1. Release the stable cut (normal release workflow) + +- [x] Deploy dev → test → UAT → prod. The test variant configs (`config.oapps.json`, + `config.cps-lon.json`) upload with it and stay unused until phase 3. +- [x] Smoke test on test: + - header and menu on the OutSystems pages and on CWA + - a `triage-submission` event arrives +- [x] Expected: `cpslon*` pages show the header with a "No context found" menu and no auth. + In test that lasts until phase 3. + +## 2. Prerequisites for QA multi-targeting + +These are additive and harmless, but must all be done before anyone switches (phase 5). +Otherwise a switched user's CWA links get a 403 from `/init`'s whitelist. + +For **`oapps-qa-notprod.int.cps.gov.uk`**: + +- [x] AAD app reg: both SPA redirect-URI forms, with the test `src` (see reference below). +- [x] `AUTH_HANDOVER_WHITELIST`: `https://oapps-qa-notprod.int.cps.gov.uk/Casework_Patterns/auth-handover.html`. +- [x] `/api/cms-modern-token` backend: accepts a return URL on this host. (No allowlist: `GetCmsModernToken.cs` in PolarisDDEI redirects to any `r`.) +- [x] `https://oapps-qa-notprod.int.cps.gov.uk/Casework_Patterns/auth-handover.html` is served. + +For **`cpslon-tst.outsystemsenterprise.com`**: + +- [x] AAD app reg: both SPA redirect-URI forms, with the test `src`. +- [x] `AUTH_HANDOVER_WHITELIST`: `https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html`. +- [x] `/api/cms-modern-token` backend: accepts a return URL on this host. (No allowlist: `GetCmsModernToken.cs` in PolarisDDEI redirects to any `r`.) +- [x] `https://cpslon-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html` is served. + +In this repo: + +- [x] Make the `urlRegex` values in `configuration/config.*.notification.json` host-agnostic. + They currently hard-code `\\.outsystemsenterprise\\.com`. Ship it in a release. + +## 3. vnext proxy (ours) + +- [x] Run `pnpm -w test:proxy` and fix anything it finds. The vnext integration tests for + variants and the switch have never been run. +- [x] Before deploying: add `CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN` to + `REQUIRED_APP_SETTINGS` and `'${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}'` to + `REQUIRED_PLACEHOLDERS` in `infra/proxy/scripts/deploy-vnext.local.sh` (gitignored, local). + The `config.json` route now uses it, and a missing app setting stops nginx starting. +- [ ] Deploy vnext to the **QA** proxy from this branch with `deploy-vnext.local.sh`. It uploads + the working tree, so it includes the swagger fix from #1134 and the switch from #1126. + There's no status/version route any more. To confirm it's live, check the routes below, + which only exist on the new vnext: `/os-target/test` is a 404 before and JSON after, and + the `X-Gloco-Config` header is absent before and present after. Swagger + (`/global-components/swagger.json`) should still work. +- [ ] Smoke test: + - `GET /global-components/test/config.json` returns `X-Gloco-Config: config.json` + - with `Origin: https://oapps-qa-notprod.int.cps.gov.uk` it returns `config.oapps.json` + - `GET /global-components/os-target/test` lists `oapps` and `cps-lon` +- [ ] From here, pages opened directly on oapps or London in test get a working component. +- [ ] Hold the **prod** proxy deploy until QA has run for a while. It takes over the + `config.json` route there too, even though prod has no variants. + +## 4. Polaris PRs (yours) + +- [ ] `/init`: port `_toOsTarget` and the `appAuthRedirect` change from our + `infra/proxy/config/main/nginx.js`, plus the "OS switch" tests in `nginx.unit.test.ts`. + Keep the rule that a rewrite is only used if `AUTH_HANDOVER_WHITELIST` accepts it. + njs has no destructuring and no `URL` class. +- [ ] Main-conf changes from this branch (`infra/proxy/config/main/global-components.ts` / `.conf`): + - `cpslon-tst` in `CORS_ALLOWED_ORIGINS` + - `/case-review-redirect` accepting a full host +- [ ] Deploy them to QA Polaris. + +## 5. Trial on test + +- [ ] Preview page: switch to `oapps`. +- [ ] CWA: reload; the menu links point at oapps; clicking one lands you on oapps with CMS auth. +- [ ] C-button lands on oapps. +- [ ] The case-review redirect lands on oapps. +- [ ] A cold-cache MSAL redirect goes through the oapps handover and back. +- [ ] Presence, triage capture and page-view analytics all work on oapps. +- [ ] Repeat for `cps-lon`. +- [ ] Switch back to Default and check you land on `cps-tst` again. +- [ ] Run `check:csp`: `test.oapps` and `test.cps-lon` appear in the report. + +## 6. Presence decision (before wider use, and certainly before the prod cutover) + +- [ ] Find out whether the `cms-auth-presence-token` cookie lands in Edge's cookie store or + IE mode's (see the presence notes below). +- [ ] Choose an option (leaning towards not opening the hub connection without a token), then + implement it and update the comment in `global-components.case-locking.ts`. + +## 7. Chunk 2: cutover to oapps, one environment at a time (dev → test → UAT → prod) + +Answer these first: + +- [ ] Will the old OS hosts redirect to oapps at cutover? + - If yes: flip our config at cutover, and Polaris updates `/launch` whenever convenient. + - If no: our config flip and Polaris's `/launch` change have to go out together. +- [ ] Will the config flip ship through the release workflow or `config-sync.sh`? + +For each environment (dev, test, UAT, prod): + +- [ ] External entries for the new host: AAD URIs (with that environment's `src`), whitelist, + handover page served. (The cms-modern-token backend has no return-URL allowlist, so it needs + nothing.) +- [ ] `configuration/config..json`: find-and-replace the OS host, including the + regex-escaped form in context paths (e.g. `cps-tst\\.outsystemsenterprise\\.com`). The + consistency spec catches misses. +- [ ] Polaris `/launch/*` and terraform `case_review_app_redirect_url` + (`/case-review-redirect//`), sequenced according to the redirect answer + above. +- [ ] Re-run `generate:csp` / `check:csp` in `cps-global-configuration`. +- [ ] Prod only: deploy `infra/analytics/kql/GloCo_PageViews.kql` to the workspace before or + with the flip. -## Background +Afterwards: -OutSystems is moving behind CPS reverse proxies, one per environment. It's a pure host swap: -same paths, same apps. +- [ ] Once each old host is retired, remove its `CORS_ALLOWED_ORIGINS` entry. +- [ ] Revisit the test variants. After test flips, `config.test.oapps.json` duplicates the base + config, so either drop it or turn it into a `legacy` variant for the old host. Keep + `OS_HOST_VARIANTS` in step with the files. -| Env | Today | After cutover | -| ---- | ----------------------------------- | ----------------------------------- | -| dev | `cps-dev.outsystemsenterprise.com` | `oapps-dev-notprod.int.cps.gov.uk` | -| test | `cps-tst.outsystemsenterprise.com` | `oapps-qa-notprod.int.cps.gov.uk` | +--- + +## Reference + +### Hosts + +| Env | Today | After cutover | +| ---- | ----------------------------------- | -------------------------------------------------- | +| dev | `cps-dev.outsystemsenterprise.com` | `oapps-dev-notprod.int.cps.gov.uk` | +| test | `cps-tst.outsystemsenterprise.com` | `oapps-qa-notprod.int.cps.gov.uk` | | uat | `cps-tst1.outsystemsenterprise.com` | `oapps-uat-notprod.int.cps.gov.uk` (assumed shape) | -| prod | `cps.outsystemsenterprise.com` | `oapps.int.cps.gov.uk` | +| prod | `cps.outsystemsenterprise.com` | `oapps.int.cps.gov.uk` | **Principle: one true OutSystems domain per environment at any given time.** + - Each env config names exactly one OS host, written out literally. -- There's no runtime host rewriting, no per-user host pins in the component, and no - multi-host alternation in context paths. -- Putting some users on a different host is done by serving them a **different config - file**, chosen by a switch that's known at C-button time (see multi-targeting below). - -### Baseline (done) - -- **`is-outsystems-host.ts`** (`packages/cps-global-configuration`) is the single answer to - "is this OutSystems?". - - It recognises `*.outsystemsenterprise.com` and `oapps(--notprod)?.int.cps.gov.uk`. - - Used by `isOutSystemsApp` (the `isOutSystems` flag and the menu/banner link adapter) and - by the CSP check targets. - - It only classifies a host, so no code changes at cutover. If UAT's real domain doesn't - fit the pattern, widen it first. -- **`outsystems-host-consistency.spec.ts`:** - - each env config names exactly one OS host across its URLs, and every OS context path - matches that host - - each variant (`config...json`) is its environment's config with only the - host swapped -- **The triage shim's activation regex** no longer depends on the host. -- **The FCT2-16735 tasklist-filter reset** is gated by - `OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN` (true in test and uat), not by a `cps-tst` - hostname check. -- **nginx `/case-review-redirect/{osHost}/{envFolder}`** accepts a full host (restricted to - `*.cps.gov.uk` / `*.outsystemsenterprise.com`) as well as the old subdomain form. -- **Removed: the London / FCT2-20670 preview-region work.** - - The component no longer loads on `cpslon*`, except test through the `cps-lon` variant. - - This doesn't matter in UAT or prod: London never ran in prod, and UAT is unrestricted. -- **CORS:** `oapps-*` hosts end in `.cps.gov.uk`, which the suffix rule in `readCorsOrigin` - already allows. They're also same-site with `polaris-*.cps.gov.uk`, so the state cookies - are first-party. - -### Multi-targeting in QA: how it works - -- **Variant configs:** - - `configuration/config.test.oapps.json` (`oapps-qa-notprod.int.cps.gov.uk`) - - `configuration/config.test.cps-lon.json` (`cpslon-tst.outsystemsenterprise.com`) - - Both deploy as `test/config..json` (deploy workflow and `config-sync*.sh`). -- **The switch** is the `Gloco-Os-Target-` cookie on the Polaris host. - - `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and its value is the OS host. - - It's per environment because dev and test share `polaris-qa-notprod`. - - Set via `PUT`/`DELETE /global-components/os-target/`, from the preview page's - "OutSystems host" control. Anyone with access to the preview page can switch. -- **The vnext proxy** (`infra/proxy/config/global-components.vnext`): - - `OS_HOST_VARIANTS` maps each variant to its host. A unit test checks it against the - variant files in both directions. - - `/global-components//config.json` serves, in order of preference: the variant for - the requesting OS page's own host (by `Origin`); the variant the cookie names (CWA); or - `config.json`. - - Config always matches the page it's running on, because auth state is per origin. -- **Polaris `/init` (`appAuthRedirect`)** is where every route into OutSystems passes - through: the C-button, CWA links and case-review. It forces Edge mode, so it can read the - cookie. - - Drafted as `_toOsTarget` in our copy, `infra/proxy/config/main/nginx.js`. - - It works out the environment from the handover's `src`, then swaps the host in `r` and - in its nested destination. - - It only uses the swap if `AUTH_HANDOVER_WHITELIST` accepts it, so a stale cookie never - causes a 403. - - Polaris's `/launch/*` doesn't change. -- **Component and handover code are unchanged.** -- **The live CSP checker** also probes the variant hosts (`test.oapps`, `test.cps-lon`). -- **Analytics:** `GloCo_PageViews.kql` folds `cps.outsystemsenterprise.com` and - `oapps.int.cps.gov.uk` into one `OsUrl` prefix, so history is continuous. It's in the - repo, not yet deployed. - -### Reference: AAD redirect URIs - -App reg `8d6133af-9593-47c6-94d0-5c65e9e310f1` needs both forms as SPA redirect URIs for -each host (per `packages/cps-global-handover/EXTERNAL-ENTRY.md`): +- There's no runtime host rewriting and no multi-host context paths. +- Putting some users on a different host is done by serving them a different config file, + chosen by a switch. + +### What the branch already does + +- `is-outsystems-host.ts` recognises `*.outsystemsenterprise.com` and + `oapps(--notprod)?.int.cps.gov.uk`, so no code changes at cutover. If UAT's real domain + doesn't fit, widen the pattern. +- `outsystems-host-consistency.spec.ts`: + - each config names exactly one OS host + - each variant (`config...json`) is its environment's config with only the host + swapped +- The triage shim activates on any host; tests cover it on oapps. +- The FCT2-16735 tasklist reset is gated by `OS_RESET_TASKLIST_FILTERS_ON_FRESH_TOKEN` + (true in test and UAT). +- The London / FCT2-20670 preview-region work is removed. +- **Multi-targeting:** + - The switch is the `Gloco-Os-Target-` cookie on the Polaris host: `Path=/`, + `SameSite=Lax`, `HttpOnly`, and its value is the OS host. + - It's set via `/global-components/os-target/` from the preview page. Anyone with the + preview page can switch. + - The vnext proxy serves `config.json` by the page's `Origin` host first, then the cookie, + then the base file. + - `/init` moves the handover onto the switched host. + - Component and handover code are unchanged. +- The CSP checker probes the variant hosts. +- `GloCo_PageViews.kql` treats the old and oapps prod hosts as one. It's in the repo, not yet + deployed. + +### AAD redirect URIs + +App reg `8d6133af-9593-47c6-94d0-5c65e9e310f1` needs both forms as SPA redirect URIs for each +host (per `packages/cps-global-handover/EXTERNAL-ENTRY.md`): ``` https:///Casework_Patterns/auth-handover.html?src= @@ -92,87 +184,34 @@ https:///Casework_Patterns/auth-handover.html?src=&stage=ad-r ``` `` for each environment: + - dev: `https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Fdev%2Fauth-handover.js` - test: `https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js` - uat: `https%3A%2F%2Fpolaris-uat-notprod.cps.gov.uk%2Fglobal-components%2Fuat%2Fauth-handover.js` - prod: `https%3A%2F%2Fpolaris.cps.gov.uk%2Fglobal-components%2Fprod%2Fauth-handover.js` -## QA multi-targeting: to get it working on test - -- [ ] Run the proxy integration tests: `pnpm -w test:proxy`. They include six new vnext - tests; none have been run yet. -- [ ] **Polaris PR for `/init`:** transpose `_toOsTarget` and the `appAuthRedirect` change - from `infra/proxy/config/main/nginx.js`, plus the "OS switch" tests in - `nginx.unit.test.ts`. Note that njs has no destructuring and no `URL` class. -- [ ] **Polaris PR for main-conf CORS:** add `https://cpslon-tst.outsystemsenterprise.com` - to `CORS_ALLOWED_ORIGINS` in `infra/proxy/config/main/global-components.ts`. -- [ ] Deploy the vnext layer (`infra/proxy/deploy/deploy.sh`). -- [ ] Deploy the variant configs, via the release workflow or `scripts/config-sync.sh`. -- [ ] **External entries,** for both `oapps-qa-notprod.int.cps.gov.uk` and - `cpslon-tst.outsystemsenterprise.com`: - - [ ] AAD redirect URIs, both forms, with the test `src` (see the reference above). - - [ ] `AUTH_HANDOVER_WHITELIST`: add `https:///Casework_Patterns/auth-handover.html`. - - [ ] `/api/cms-modern-token` backend (behind `/auth-refresh-cms-modern-token`): confirm it - accepts a return URL on ``, or add it to the allowlist if there is one. - - [ ] Confirm `https:///Casework_Patterns/auth-handover.html` is served. -- [ ] **Notifications:** `configuration/config.*.notification.json` `urlRegex` values - hard-code `\\.outsystemsenterprise\\.com`, so they won't match on oapps pages. Make - them host-agnostic. -- [ ] **Trial on test:** - - switch via the preview page - - C-button, CWA menu links, case-review redirect - - cold-cache MSAL redirect - - switching back to Default - -## Presence on oapps: to discuss - -- On oapps pages (same-site with Polaris), the `SameSite=Lax` `cms-auth-presence-token` - cookie (`Path=/global-components/case-locking`, set by the CMS-estate auth callback) will - start being sent with the component's requests. -- `presenceBearer` still lets a client `Authorization` header win. The difference is when - the component has **no** MSAL token: today that negotiate request gets a 401; on oapps it - would authenticate with the CMS-estate token instead. -- The component uses SSE or long polling, so after negotiate it talks to `/api/sr/`, which - never reads the cookie. So only negotiate and REST calls are affected. +### Presence notes (phase 6) + +- On oapps pages, which are same-site with Polaris, the `SameSite=Lax` + `cms-auth-presence-token` cookie (`Path=/global-components/case-locking`, set by the + CMS-estate auth callback) will start being sent with the component's requests. +- `presenceBearer` still lets a client `Authorization` header win. The difference is when the + component has **no** MSAL token: today that negotiate request gets a 401; on oapps it would + authenticate with the CMS-estate token instead. +- Only negotiate and REST calls are affected. After negotiate, the component (SSE or long + polling) talks to `/api/sr/`, which never reads the cookie. - **Options:** - 1. Accept it, and update the comment in `global-components.case-locking.ts`. - 2. *(leaning)* Don't open the hub connection without a token (`case-locking-presence.ts`). + 1. Accept it, and update the comment. + 2. _(leaning)_ Don't open the hub connection without a token (`case-locking-presence.ts`). 3. Only let `presenceBearer` fall back to the cookie for CMS-estate callers, by `Origin`. This is fragile, since CMS hosts are under `.cps.gov.uk` too. -- **Open question:** does the presence cookie end up in Edge's cookie store or IE mode's? - If IE mode, the case never happens. -## Chunk 2: the per-environment cutover to oapps +### What to expect on each cutover day -For each environment: - -- [ ] External entries: AAD URIs (with that environment's `src`), the whitelist entry, the - cms-modern-token backend, and confirming the handover page is served on the new host. -- [ ] `configuration/config..json`: find-and-replace the OS host, including the - regex-escaped form in context paths (e.g. `cps-tst\\.outsystemsenterprise\\.com`). - The consistency spec catches misses. -- [ ] Polaris `/launch/*` (their nginx; `infra/proxy/config/main/nginx-full.conf` here is a - reference copy, not deployed): repoint the hard-coded handover and the double-encoded - destination. -- [ ] Polaris terraform `case_review_app_redirect_url`: change to - `/case-review-redirect//`. -- [ ] Re-run the CSP tooling (`generate:csp` / `check:csp` in `cps-global-configuration`). -- [ ] After the old host is retired: remove its `CORS_ALLOWED_ORIGINS` entry. -- [ ] Prod only: deploy the `GloCo_PageViews.kql` change before or with the prod cutover. - -What to expect on the day: - -- Every user starts with empty per-origin state on the new host: MSAL cache, OS ClientVars - and CMS auth. +- Every user starts with empty per-origin state on the new host: MSAL cache, OS ClientVars and + CMS auth. - The first OS page load does one AAD round trip, through the new host's handover. - CMS auth arrives through the C-button / handover chain. -- Old-host bookmarks stop getting the component once the config moves. If the old host - stays live, that's for OutSystems or infra to handle with a redirect, not for multiple - hosts in our config. - -Open questions: - -- Will the old OS hosts redirect to oapps at cutover? - - If yes: flip our config at cutover, and Polaris updates `/launch` whenever convenient. - - If no: our config flip and Polaris's `/launch` change have to go out together. -- Does the config flip ship through the release workflow or `config-sync.sh`? +- Old-host bookmarks stop getting a working component once the config moves. If the old host + stays live, that's for OutSystems or infra to handle with a redirect, not for multiple hosts + in our config. From f6e53c0e3efa1047f6efaa193b518a13ce2702a2 Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Tue, 29 Sep 2026 17:02:04 +0100 Subject: [PATCH 6/8] #FCT2-22132 - feature complete on os domain switching --- TODO.md | 55 ++- infra/proxy/README.md | 38 +- ...al-components.multi-os-domain-testing.conf | 116 ++++++ ...obal-components.multi-os-domain-testing.ts | 242 ++++++++++++ ...ulti-os-domain-testing.integration.test.js | 168 +++++++++ ...nents.multi-os-domain-testing.unit.test.ts | 357 ++++++++++++++++++ .../global-components.vnext.conf | 53 --- .../global-components.vnext.ts | 144 ------- ...lobal-components.vnext.integration.test.js | 74 +--- .../global-components.vnext.unit.test.ts | 133 ------- infra/proxy/config/main/nginx.js | 75 +--- .../config/main/tests/nginx.unit.test.ts | 93 ----- infra/proxy/deploy/deploy.sh | 3 + infra/proxy/deploy/rollback.sh | 3 + ...docker-compose.multi-os-domain-testing.yml | 12 + .../docker/test-only.polaris-launch.conf | 18 + infra/proxy/package.json | 2 +- infra/proxy/run-tests.sh | 13 +- infra/proxy/scripts/build.sh | 4 + packages/cps-global-preview/src/App.tsx | 41 +- 20 files changed, 1023 insertions(+), 621 deletions(-) create mode 100644 infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf create mode 100644 infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.ts create mode 100644 infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js create mode 100644 infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.unit.test.ts create mode 100644 infra/proxy/docker/docker-compose.multi-os-domain-testing.yml create mode 100644 infra/proxy/docker/test-only.polaris-launch.conf diff --git a/TODO.md b/TODO.md index d24172f87..d414151ae 100644 --- a/TODO.md +++ b/TODO.md @@ -66,23 +66,42 @@ In this repo: - [ ] Hold the **prod** proxy deploy until QA has run for a while. It takes over the `config.json` route there too, even though prod has no variants. -## 4. Polaris PRs (yours) - -- [ ] `/init`: port `_toOsTarget` and the `appAuthRedirect` change from our - `infra/proxy/config/main/nginx.js`, plus the "OS switch" tests in `nginx.unit.test.ts`. - Keep the rule that a rewrite is only used if `AUTH_HANDOVER_WHITELIST` accepts it. - njs has no destructuring and no `URL` class. -- [ ] Main-conf changes from this branch (`infra/proxy/config/main/global-components.ts` / `.conf`): - - `cpslon-tst` in `CORS_ALLOWED_ORIGINS` +## 4. Multi-OS-domain testing module (ours) — replaces the vnext switch + +The switch now lives in one temporary module, `infra/proxy/config/global-components.multi-os-domain-testing/`. +It takes over `config.json` selection and the status route from vnext, adds the both-engines +set route, and overrides the proxied-CMS C-button routes. Nothing in Polaris changes (`/init`, +`/launch`, `uainGeneratedScript`), and users without the signal cookie are handed back +untouched. + +- [ ] Run `pnpm -w test:proxy`. There's a new layer, `multi-os-domain-testing`, and vnext has + lost its variant tests. +- [ ] **Deploy order matters.** Both the old vnext and the module define + `location = /global-components//config.json`. If both are live, nginx won't start and + the whole proxy goes down. + 1. Deploy the stripped vnext first (`deploy-vnext.local.sh`). QA briefly loses variant config + selection until step 2. + 2. Then deploy the module (`deploy-multi-os-domain-testing.local.sh`). It refuses to upload + while the deployed vnext conf still defines `config.json` locations. +- [ ] Smoke test: + - `GET /global-components/multi-os/target/test` lists `oapps` and `cps-lon` + - `test/config.json` still returns `X-Gloco-Config: config.json`, and `config.oapps.json` for + `Origin: https://oapps-qa-notprod.int.cps.gov.uk` + - a proxied-CMS C-button click **without** the signal still goes where it always did +- [ ] Main-conf changes, still needed and deployed with the parent project + (`infra/proxy/config/main/global-components.ts` / `.conf`): + - `cpslon-tst` in `CORS_ALLOWED_ORIGINS` (state fetches from London pages) - `/case-review-redirect` accepting a full host -- [ ] Deploy them to QA Polaris. ## 5. Trial on test - [ ] Preview page: switch to `oapps`. - [ ] CWA: reload; the menu links point at oapps; clicking one lands you on oapps with CMS auth. -- [ ] C-button lands on oapps. -- [ ] The case-review redirect lands on oapps. +- [ ] Preview page switch: the page reloads via IE mode and comes back showing `oapps` (both + cookie copies written). +- [ ] Proxied-CMS C-button lands on oapps. +- [ ] A user WITHOUT the signal: proxied-CMS C-button unchanged. +- [ ] (Not covered by the module: the case-review redirect and raw-CMS C-button stay on `cps-tst`.) - [ ] A cold-cache MSAL redirect goes through the oapps handover and back. - [ ] Presence, triage capture and page-view analytics all work on oapps. - [ ] Repeat for `cps-lon`. @@ -161,13 +180,15 @@ Afterwards: (true in test and UAT). - The London / FCT2-20670 preview-region work is removed. - **Multi-targeting:** - - The switch is the `Gloco-Os-Target-` cookie on the Polaris host: `Path=/`, + - The switch is the `Gloco-Os-Target-` cookie on the Polaris host, in BOTH Edge and IE mode's stores: `Path=/`, `SameSite=Lax`, `HttpOnly`, and its value is the OS host. - - It's set via `/global-components/os-target/` from the preview page. Anyone with the - preview page can switch. - - The vnext proxy serves `config.json` by the page's `Origin` host first, then the cookie, - then the base file. - - `/init` moves the handover onto the switched host. + - It's written by `/global-components/multi-os/set` (navigated to from the preview page), which + visits both engines. Anyone with the preview page can switch. + - The multi-os-domain-testing module serves `config.json` by the page's `Origin` host first, + then the cookie, then the base file. + - Its `/launch/cin2`–`cin5-proxy` overrides send switched users' proxied-CMS C-button through + the same `/polaris` → `/init` chain on the chosen host. Everyone else is handed back to + Polaris's own route. - Component and handover code are unchanged. - The CSP checker probes the variant hosts. - `GloCo_PageViews.kql` treats the old and oapps prod hosts as one. It's in the repo, not yet diff --git a/infra/proxy/README.md b/infra/proxy/README.md index ecec64a12..2e590f8ac 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -22,21 +22,35 @@ Nginx reverse proxy with njs (JavaScript) for header/cookie manipulation. Used t - `.env` - **gitignored** - contains vnext-specific config - `.env.example` - template for the above -The vnext layer also owns **OS host variants**: letting named users be switched onto a -different OutSystems host from the rest of their environment. - -- `configuration/config...json` is the environment's config with only the OS - host swapped (enforced by `outsystems-host-consistency.spec.ts`), deployed beside - `config.json` as `config..json`. -- `OS_HOST_VARIANTS` in `global-components.vnext.ts` maps each variant to its host. A unit - test checks it against the variant files both ways. -- The switch is the `Gloco-Os-Target-` cookie (`Path=/`, value = the variant's OS host), - set via `PUT`/`DELETE /global-components/os-target/` from the preview page. -- `/global-components//config.json` is served by `readConfigBlobName`: +### Multi-OS-domain testing (`config/global-components.multi-os-domain-testing/`) — TEMPORARY + +A self-contained module (FCT2-22132) that lets designated QA users run against a different +OutSystems host (the oapps proxy, the London tenant) while everyone else is untouched. It only +ADDS locations; where one shadows an existing route, a request without the signal cookie is +handed straight back to that route's own block. To end the trial, delete the module directory +and its lines in `scripts/build.sh`, `deploy/deploy.sh`, `deploy/rollback.sh`, `run-tests.sh`, +`package.json` and `docker/` (`docker-compose.multi-os-domain-testing.yml`, +`test-only.polaris-launch.conf`). + +- **Signal:** the `Gloco-Os-Target-` cookie (`Path=/`, `HttpOnly`, value = the chosen OS + host). Edge and IE mode keep separate cookie stores, so it is written into both by + `/global-components/multi-os/set?env=&host=&return=`, the only writer. That route flips the + tab between engines with `X-InternetExplorerMode`. An empty `host` clears both copies. + `/global-components/multi-os/target/` reports the current value to the preview page. +- **Variants:** `configuration/config...json` is the environment's config with + only the OS host swapped (enforced by `outsystems-host-consistency.spec.ts`), deployed beside + `config.json` as `config..json`. `OS_HOST_VARIANTS` maps each variant to its host; a + unit test checks it against the files both ways. +- **`config.json`:** served by `readConfigBlobName`: - the variant for the requesting OS page's own host (by `Origin`); else - the variant the cookie names (CWA); else - `config.json`. -- Polaris's `/init` reads the same cookie to move the handover onto the switched host. +- **Proxied-CMS C-button:** exact-match overrides of Polaris's `/launch/cin2`–`cin5-proxy`. + - Without the signal, they rewrite to `…-proxy/`, which only Polaris's prefix locations match, + so Polaris's own redirect runs unchanged. + - With it (the click arrives in IE mode, carrying IE mode's copy), they redirect to Polaris's + same `/polaris?r=` target, with the OS host swapped. + - `/polaris`, `/init`, raw-CMS `/launch/cinN` and prod's `/launch/cms-proxy` are not touched. ### Docker (`docker/`) diff --git a/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf b/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf new file mode 100644 index 000000000..5aaea2684 --- /dev/null +++ b/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf @@ -0,0 +1,116 @@ +# --------------------------------------------------------------------------- +# Multi-OS-domain testing (FCT2-22132) — TEMPORARY +# +# Lets designated QA users run against a different OutSystems host (the oapps +# proxy, the London tenant) while everyone else is untouched. See the header of +# global-components.multi-os-domain-testing.ts for the design. When the trial is +# over, delete this module (and its build/deploy/test wiring) — nothing else +# depends on it, and it edits nothing else: it only ADDS locations. Where one of +# them shadows an existing route, a request without the signal cookie is handed +# straight back to that route's own block, unchanged. +# +# Picked up by `include global-components*.conf;` in the server block, next to +# Polaris's own locations and the other global-components confs. +# --------------------------------------------------------------------------- + +js_import glocomultios from templates/global-components.multi-os-domain-testing.js; + +js_set $gloco_config_blob glocomultios.readConfigBlobName; +js_set $gloco_multi_os_launch glocomultios.launchTarget; + +# ---- config.json selection ------------------------------------------------- +# +# config.json is served as config.json or config..json, chosen by +# readConfigBlobName from the page's Origin (OS pages) or the signal cookie +# (CWA). Exact-match locations win over the main conf's regex location for +# /global-components//..., so these take over config.json alone and leave +# every other artifact where it is. Each records its environment and hands over +# to one internal location, rather than repeating the proxy block per env. + +location = /global-components/dev/config.json { set $gloco_config_env dev; rewrite ^ /global-components/__config last; } +location = /global-components/test/config.json { set $gloco_config_env test; rewrite ^ /global-components/__config last; } +location = /global-components/uat/config.json { set $gloco_config_env uat; rewrite ^ /global-components/__config last; } +location = /global-components/prod/config.json { set $gloco_config_env prod; rewrite ^ /global-components/__config last; } + +location = /global-components/__config { + internal; + + error_page 418 = @gloco_preflight; + if ($request_method = OPTIONS) { + return 418; + } + + # As the main conf's artifact location (see its comments for the reasoning). + gzip on; + gzip_types application/json; + gzip_proxied any; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 5; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Credentials; + add_header Access-Control-Allow-Origin * always; + # The body depends on who is asking, so no shared cache may reuse it across them. + add_header Vary "Origin, Cookie" always; + add_header X-Gloco-Config $gloco_config_blob always; + + resolver ${WEBSITE_DNS_SERVER} ipv6=off valid=30s; + proxy_ssl_server_name on; + proxy_ssl_verify off; + proxy_pass_request_headers off; + proxy_set_header Host ${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}; + proxy_pass https://${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}/$gloco_config_env/$gloco_config_blob; +} + +# ---- Proxied-CMS C-button -------------------------------------------------- +# +# The proxied CMS's C-button points at /launch/-proxy (Polaris rewrites +# CMS's CASEWORK_TOOLS_URL to it in uainGeneratedScript.aspx). Polaris serves +# those as PREFIX locations (`location /launch/cin3-proxy { return 302 ...; }`), +# so these EXACT matches take precedence for that one URL: +# - no signal: rewrite to the same path plus "/", which only Polaris's prefix +# location matches, so THEIR block runs, exactly as today (and follows any +# change they make to it); +# - signal: send the user through the same handover chain, on the chosen host. +# The click arrives in IE mode, so the cookie read is the IE-mode copy that the +# set route wrote. /polaris and /init then run completely unchanged. + +location = /launch/cin2-proxy { + if ($gloco_multi_os_launch = "") { + rewrite ^ /launch/cin2-proxy/ last; + } + return 302 $gloco_multi_os_launch; +} + +location = /launch/cin3-proxy { + if ($gloco_multi_os_launch = "") { + rewrite ^ /launch/cin3-proxy/ last; + } + return 302 $gloco_multi_os_launch; +} + +location = /launch/cin4-proxy { + if ($gloco_multi_os_launch = "") { + rewrite ^ /launch/cin4-proxy/ last; + } + return 302 $gloco_multi_os_launch; +} + +location = /launch/cin5-proxy { + if ($gloco_multi_os_launch = "") { + rewrite ^ /launch/cin5-proxy/ last; + } + return 302 $gloco_multi_os_launch; +} + +# ---- The switch (driven by the preview page, same-origin, so no CORS) ------- + +# The only writer of the signal: visits both engines to set/clear both copies. +location = /global-components/multi-os/set { + js_content glocomultios.handleSet; +} + +location ~ ^/global-components/multi-os/target/(dev|test|uat|prod)$ { + js_content glocomultios.handleStatus; +} diff --git a/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.ts b/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.ts new file mode 100644 index 000000000..ceb714237 --- /dev/null +++ b/infra/proxy/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.ts @@ -0,0 +1,242 @@ +// --------------------------------------------------------------------------- +// Multi-OS-domain testing (FCT2-22132) +// +// TEMPORARY. Lets designated QA users run against a different OutSystems host +// (the oapps proxy, the London tenant) while everyone else stays on their +// environment's own host. Everything for it lives in this module and its .conf, +// so ending the trial is deleting this directory (plus its build/deploy/test +// wiring). Nothing here edits another team's config: it only adds locations. +// +// The signal is a per-environment cookie on this (polaris) host, +// Gloco-Os-Target-= +// written into BOTH browser engines' cookie stores (Edge, and Edge's IE mode, +// which keeps its own) by the set route below, which is the only writer. Three +// things read it: +// - config.json selection, so the component on CWA gets the variant config; +// - the /launch/-proxy overrides, so the proxied-CMS C-button (clicked in +// IE mode) sends the user through the handover chain on the chosen host; +// - the status route, for the preview page's display. +// +// Without the cookie, every route here hands straight back to what already +// exists, unchanged. +// --------------------------------------------------------------------------- + +// Each environment's OS host variants. Must match the variant config files in +// configuration/ (config...json) — the unit tests check both ways. +const OS_HOST_VARIANTS: Record> = { + test: { + oapps: "oapps-qa-notprod.int.cps.gov.uk", + "cps-lon": "cpslon-tst.outsystemsenterprise.com", + }, +}; + +// The Polaris host each environment's handover chain runs through: it serves +// the auth-handover.js bundle (the handover URL's src, which the OS-hosted +// auth-handover.html allowlists and AAD's redirect URIs embed) and /polaris. +const POLARIS_HOSTS: Record = { + test: "polaris-qa-notprod.cps.gov.uk", +}; + +// The proxied-CMS C-button routes we override, and the environment each serves. +// These mirror Polaris's own `location /launch/-proxy` blocks, which all +// send the user to the test handover on polaris-qa-notprod. +const LAUNCH_ROUTES: Record = { + "cin2-proxy": "test", + "cin3-proxy": "test", + "cin4-proxy": "test", + "cin5-proxy": "test", +}; + +const COOKIE_PREFIX = "Gloco-Os-Target-"; +const COOKIE_LIFESPAN_MS = 365 * 24 * 60 * 60 * 1000; + +function _getCookie(r: NginxHTTPRequest, name: string): string | undefined { + const cookies = (r.headersIn["Cookie"] as string | undefined) || ""; + const parts = cookies.split(";"); + for (let i = 0; i < parts.length; i++) { + const part = parts[i].trim(); + if (part.indexOf(name + "=") === 0) { + return decodeURIComponent(part.substring(name.length + 1)); + } + } + return undefined; +} + +function _variantForHost(env: string, host: string | undefined): string | undefined { + if (!host) { + return undefined; + } + const variants = OS_HOST_VARIANTS[env] || {}; + const names = Object.keys(variants); + for (let i = 0; i < names.length; i++) { + if (variants[names[i]] === host.toLowerCase()) { + return names[i]; + } + } + return undefined; +} + +// The switched-to host for an environment, if the request carries a valid signal. +function _targetHost(r: NginxHTTPRequest, env: string): string | undefined { + const host = _getCookie(r, COOKIE_PREFIX + env); + return _variantForHost(env, host) ? host!.toLowerCase() : undefined; +} + +function _hostOf(url: string | undefined): string | undefined { + const match = (url || "").match(/^https?:\/\/([^/:]+)/i); + return match ? match[1].toLowerCase() : undefined; +} + +function _query(r: NginxHTTPRequest, name: string): string { + const value = r.args[name]; + return typeof value === "string" ? value : ""; +} + +// ---- config.json selection ------------------------------------------------ + +// js_set for the config.json locations: which blob to serve. +// - Cross-origin (the component or the handover on an OS page, which sends +// Origin): the config for THAT page's host, whatever the switch says. MSAL +// tokens, OS ClientVars and CMS auth are all per-origin, so config has to +// match the page it runs on. +// - Same-origin (the component on CWA, which sends the cookie but no Origin): +// the variant the switch names, if any. +// - Anything else, or a host with no variant: config.json. +function readConfigBlobName(r: NginxHTTPRequest): string { + // Set by the per-environment config.json location before it hands over to + // the shared internal location, so read it rather than the (rewritten) URI. + const env = (r.variables.gloco_config_env as string | undefined) || ""; + const originHost = _hostOf(r.headersIn["Origin"] as string | undefined); + const requestHost = ((r.headersIn["Host"] as string | undefined) || "").split(":")[0].toLowerCase(); + const isCrossOrigin = !!originHost && originHost !== requestHost; + + const variant = isCrossOrigin ? _variantForHost(env, originHost) : _variantForHost(env, _targetHost(r, env)); + + return variant ? "config." + variant + ".json" : "config.json"; +} + +// ---- C-button (/launch/-proxy) -------------------------------------- + +// The handover URL Polaris's /launch/-proxy routes point at, on `osHost`: +// https:///polaris?r= +// Same shape and encoding as their hard-coded targets, only the host differs. +function _launchUrl(env: string, osHost: string): string { + const polaris = POLARIS_HOSTS[env]; + const src = "https://" + polaris + "/global-components/" + env + "/auth-handover.js"; + const home = "https://" + osHost + "/casework_blocks/home?IsFromCMS=True"; + // src is left raw and only the nested r is encoded, exactly as Polaris's own + // targets have it (the unit tests compare against them). + const handover = + "https://" + osHost + "/Casework_Patterns/auth-handover.html?src=" + src + "&stage=os-cookie-return&r=" + encodeURIComponent(home); + return "https://" + polaris + "/polaris?r=" + encodeURIComponent(handover); +} + +// js_set for the /launch/-proxy overrides: where a switched user's C-button +// goes, or "" to hand the request back to Polaris's own location untouched. +// The C-button is clicked in the proxied CMS, i.e. in IE mode, so this reads the +// IE-mode copy of the cookie — which the set route wrote. +function launchTarget(r: NginxHTTPRequest): string { + const route = (r.uri.match(/^\/launch\/([^/]+)$/) || [])[1] || ""; + const env = LAUNCH_ROUTES[route]; + if (!env || !POLARIS_HOSTS[env]) { + return ""; + } + const host = _targetHost(r, env); + return host ? _launchUrl(env, host) : ""; +} + +// ---- The switch: status + set --------------------------------------------- + +// GET /global-components/multi-os/target/ +// → { current: | null, options: [{ variant, host }] } +// Reads the Edge copy (the preview page runs in Edge); the set route writes both +// copies together, so it speaks for both. Same-origin only, so no CORS. +function handleStatus(r: NginxHTTPRequest): void { + const env = (r.uri.match(/^\/global-components\/multi-os\/target\/([^/]+)$/) || [])[1] || ""; + const variants = OS_HOST_VARIANTS[env] || {}; + r.headersOut["Content-Type"] = "application/json"; + if (r.method !== "GET") { + r.return(405, JSON.stringify({ error: "Method not allowed" })); + return; + } + r.return( + 200, + JSON.stringify({ + current: _targetHost(r, env) || null, + options: Object.keys(variants).map(variant => ({ variant, host: variants[variant] })), + }), + ); +} + +// GET /global-components/multi-os/set?env=&host=&return= +// +// The ONLY writer of the signal. Edge and IE mode keep separate cookie stores for +// this host, and the signal must be in both: Edge for CWA's config.json, IE mode +// for the proxied-CMS C-button. So this is a navigation (not a fetch) that +// visits both engines, the same way Polaris's own routes flip engines: +// 1. set the cookie in the engine the request arrived in; +// 2. if the site is IE-configurable, answer with X-InternetExplorerMode and a +// redirect to itself (done=), so the browser re-requests in the +// other engine, which sets its copy; +// 3. redirect to `return`, in Edge. +// An empty host clears both copies. Only ever one hop, so a flip the browser +// declines can't loop. +function handleSet(r: NginxHTTPRequest): void { + const env = _query(r, "env"); + const host = _query(r, "host").toLowerCase(); + const returnTo = _query(r, "return"); + const done = _query(r, "done"); + + if (!OS_HOST_VARIANTS[env] || (host && !_variantForHost(env, host))) { + r.headersOut["Content-Type"] = "application/json"; + r.return(400, JSON.stringify({ error: "unknown environment or OS host" })); + return; + } + + // Same-host paths only, so this can't be used to bounce users elsewhere. + const safeReturn = /^\/(?!\/)/.test(returnTo) ? returnTo : "/"; + + const ieaction = (r.variables.ieaction as string | undefined) || ""; + const engine = ieaction.indexOf("ie+") === 0 ? "ie" : "edge"; + const configurable = ieaction.indexOf("+configurable+") !== -1; + + const expires = host ? new Date(Date.now() + COOKIE_LIFESPAN_MS) : new Date(0); + // njs types Set-Cookie as string[]; a single string is fine at runtime + // (same cast as _setCookie in main/global-components.ts). + (r.headersOut as Record)["Set-Cookie"] = + COOKIE_PREFIX + env + "=" + encodeURIComponent(host) + "; Path=/; Expires=" + expires.toUTCString() + "; Secure; HttpOnly; SameSite=Lax"; + + if (!done && configurable) { + // Over to the other engine to write its copy. + r.headersOut["X-InternetExplorerMode"] = engine === "edge" ? "1" : "0"; + r.return( + 302, + "/global-components/multi-os/set?env=" + + encodeURIComponent(env) + + "&host=" + + encodeURIComponent(host) + + "&return=" + + encodeURIComponent(safeReturn) + + "&done=" + + engine, + ); + return; + } + + // Both copies written (or the site can't flip). Finish in Edge: the preview + // page lives there. + if (engine === "ie" && configurable) { + r.headersOut["X-InternetExplorerMode"] = "0"; + } + r.return(302, safeReturn); +} + +export default { + readConfigBlobName, + launchTarget, + handleStatus, + handleSet, + OS_HOST_VARIANTS, + POLARIS_HOSTS, + LAUNCH_ROUTES, +}; diff --git a/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js new file mode 100644 index 000000000..a2cf84ffc --- /dev/null +++ b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Integration tests for global-components.multi-os-domain-testing.conf.template + * + * Runs against the docker stack with the module layer mounted, which also mounts + * test-only stand-ins for Polaris's /launch routes (docker/test-only.polaris-launch.conf) + * so the C-button overrides' hand-back can be proven end to end. + */ + +const { + PROXY_BASE, + assert, + assertEqual, + test, + fetchJson, + getState, + resetState, +} = require("../../../test-utils") + +const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" +const SIGNAL = `Gloco-Os-Target-test=${OAPPS}` +// IE mode, as a proxied-CMS request arrives: Trident UA on an IE-configurable site. +const IE_MODE = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", + "X-InternetExplorerModeConfigurable": "1", +} +const EDGE_CONFIGURABLE = { "X-InternetExplorerModeConfigurable": "1" } + +const redirectOf = async (path, headers = {}) => { + const response = await fetch(`${PROXY_BASE}${path}`, { headers, redirect: "manual" }) + return { status: response.status, location: response.headers.get("location"), response } +} + +async function testConfigSelection() { + console.log("\nconfig.json selection:") + + const CONFIG_URL = `${PROXY_BASE}/global-components/test/config.json` + const blobFileOf = response => response.headers.get("x-mock-blob-file") + + await test("serves config.json from blob storage when there is no signal", async () => { + const response = await fetch(CONFIG_URL) + assertEqual(response.status, 200, "Should return 200") + assertEqual(blobFileOf(response), "config.json", "Should serve the base config") + assert((response.headers.get("vary") || "").includes("Cookie"), "Should vary on Cookie") + }) + + await test("serves the variant named by the signal cookie", async () => { + const response = await fetch(CONFIG_URL, { headers: { Cookie: SIGNAL } }) + assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") + }) + + await test("serves the variant matching an OS page's Origin", async () => { + const response = await fetch(CONFIG_URL, { headers: { Origin: `https://${OAPPS}` } }) + assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") + assertEqual(response.headers.get("access-control-allow-origin"), "*", "Should keep CORS") + }) + + await test("leaves every other artifact to the main conf", async () => { + const response = await fetch(`${PROXY_BASE}/global-components/test/global-components.js`, { + headers: { Cookie: SIGNAL }, + }) + assertEqual(blobFileOf(response), "global-components.js", "Should be untouched") + }) +} + +async function testCButton() { + console.log("\nC-button (/launch/-proxy overrides):") + + await test("without the signal, hands back to the existing launch route untouched", async () => { + const { status, location } = await redirectOf("/launch/cin3-proxy", IE_MODE) + assertEqual(status, 302, "Should redirect") + assertEqual(location, "https://polaris-qa-notprod.cps.gov.uk/polaris?r=STAND-IN-cin3-proxy", "Should be the existing route's own target") + }) + + await test("with an unknown host in the cookie, still hands back", async () => { + const { location } = await redirectOf("/launch/cin3-proxy", { ...IE_MODE, Cookie: "Gloco-Os-Target-test=evil.example.com" }) + assertEqual(location, "https://polaris-qa-notprod.cps.gov.uk/polaris?r=STAND-IN-cin3-proxy", "Should be the existing route's own target") + }) + + await test("with the signal, sends the user through the handover chain on the chosen host", async () => { + const { status, location } = await redirectOf("/launch/cin3-proxy", { ...IE_MODE, Cookie: SIGNAL }) + assertEqual(status, 302, "Should redirect") + assert(location.startsWith("https://polaris-qa-notprod.cps.gov.uk/polaris?r="), `Should go via /polaris, got: ${location}`) + const handover = decodeURIComponent(location.split("?r=")[1]) + assert( + handover.startsWith(`https://${OAPPS}/Casework_Patterns/auth-handover.html?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js&stage=os-cookie-return&r=`), + `Should target the chosen host's handover, got: ${handover}` + ) + assert( + decodeURIComponent(handover.split("&r=")[1]) === `https://${OAPPS}/casework_blocks/home?IsFromCMS=True`, + `Should land on the chosen host's home, got: ${handover}` + ) + }) + + await test("leaves routes it doesn't override alone, signal or not", async () => { + const { location } = await redirectOf("/launch/cin3", { Cookie: SIGNAL }) + assertEqual(location, "https://cin3.cps.gov.uk/polaris?r=STAND-IN-cin3", "Should be the existing route's own target") + }) +} + +async function testSwitch() { + console.log("\nThe switch (status + set):") + + const setPath = host => + `/global-components/multi-os/set?env=test&host=${encodeURIComponent(host)}&return=${encodeURIComponent("/global-components/test/preview/")}` + + await test("status lists the variants and reads the signal", async () => { + const body = await fetchJson(`${PROXY_BASE}/global-components/multi-os/target/test`, { headers: { Cookie: SIGNAL } }) + assertEqual(body.current, OAPPS, "Should report the signal") + assertEqual(body.options.length, 2, "Should list oapps and cps-lon") + }) + + await test("set, from Edge: writes the cookie and flips to IE mode for the second copy", async () => { + const { status, location, response } = await redirectOf(setPath(OAPPS), EDGE_CONFIGURABLE) + assertEqual(status, 302, "Should redirect") + assertEqual(response.headers.get("x-internetexplorermode"), "1", "Should flip to IE mode") + assert((response.headers.get("set-cookie") || "").startsWith(SIGNAL), "Should set the Edge copy") + assert(location.includes("&done=edge"), `Should hop back to itself, got: ${location}`) + }) + + await test("set, in IE mode: writes the second copy and returns to the preview page in Edge", async () => { + const { location, response } = await redirectOf(`${setPath(OAPPS)}&done=edge`, IE_MODE) + assertEqual(response.headers.get("x-internetexplorermode"), "0", "Should flip back to Edge") + assert((response.headers.get("set-cookie") || "").startsWith(SIGNAL), "Should set the IE-mode copy") + assertEqual(location, "/global-components/test/preview/", "Should return to the preview page") + }) + + await test("set with no host clears the signal", async () => { + const { response } = await redirectOf(setPath(""), EDGE_CONFIGURABLE) + assert((response.headers.get("set-cookie") || "").includes("1970"), "Should expire the cookie") + }) + + await test("set refuses a host that is not a variant", async () => { + const { status } = await redirectOf(setPath("evil.example.com"), EDGE_CONFIGURABLE) + assertEqual(status, 400, "Should return 400") + }) +} + +async function main() { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" + + await testConfigSelection() + await testCButton() + await testSwitch() +} + +module.exports = main + +if (require.main === module) { + resetState() + console.log("=".repeat(60)) + console.log("Multi-OS-domain testing Integration Tests") + console.log(`Target: ${PROXY_BASE}`) + console.log("=".repeat(60)) + + main() + .then(() => { + const state = getState() + console.log("\n" + "=".repeat(60)) + console.log(`Results: ${state.passed} passed, ${state.failed} failed`) + console.log("=".repeat(60)) + process.exit(state.failed > 0 ? 1 : 0) + }) + .catch(err => { + console.error("\nTest suite error:", err.message) + process.exit(1) + }) +} diff --git a/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.unit.test.ts b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.unit.test.ts new file mode 100644 index 000000000..5709701ea --- /dev/null +++ b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.unit.test.ts @@ -0,0 +1,357 @@ +#!/usr/bin/env npx ts-node +/** + * Unit tests for global-components.multi-os-domain-testing.ts + * + * Uses esbuild to bundle the njs module, then runs the tests against the bundle. + */ + +import * as esbuild from "esbuild" +import * as path from "path" +import * as fs from "fs" + +const CONFIG_DIR = path.join(__dirname, "..", "..") +const MODULE_DIR = path.join(CONFIG_DIR, "global-components.multi-os-domain-testing") +const REPO_CONFIGURATION_DIR = path.join(CONFIG_DIR, "..", "..", "..", "configuration") +const DIST_DIR = path.join(CONFIG_DIR, "..", ".dist") + +if (!fs.existsSync(DIST_DIR)) { + fs.mkdirSync(DIST_DIR, { recursive: true }) +} + +interface MockRequest { + method: string + uri: string + args: Record + headersIn: Record + headersOut: Record + variables: Record + returnCode: number | null + returnBody: string | null + return(code: number, body: string): void +} + +interface MockRequestOptions { + method?: string + uri?: string + args?: Record + headersIn?: Record + variables?: Record +} + +interface MultiOsModule { + readConfigBlobName(r: MockRequest): string + launchTarget(r: MockRequest): string + handleStatus(r: MockRequest): void + handleSet(r: MockRequest): void + OS_HOST_VARIANTS: Record> + POLARIS_HOSTS: Record + LAUNCH_ROUTES: Record +} + +async function build(): Promise { + await esbuild.build({ + entryPoints: [path.join(MODULE_DIR, "global-components.multi-os-domain-testing.ts")], + bundle: true, + outfile: path.join(DIST_DIR, "global-components.multi-os-domain-testing.bundle.js"), + format: "esm", + platform: "node", + logLevel: "error", + }) +} + +async function runTests(): Promise { + const module = await import(path.join(DIST_DIR, "global-components.multi-os-domain-testing.bundle.js")) + const multiOs: MultiOsModule = module.default + + let passed = 0 + let failed = 0 + + function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message) + } + + function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\n Expected: ${JSON.stringify(expected)}\n Actual: ${JSON.stringify(actual)}`) + } + } + + async function test(name: string, fn: () => Promise): Promise { + try { + await fn() + passed++ + console.log(` \x1b[32m✓\x1b[0m ${name}`) + } catch (err) { + failed++ + console.log(` \x1b[31m✗\x1b[0m ${name}`) + console.log(` ${(err as Error).message}`) + } + } + + function createMockRequest(options: MockRequestOptions = {}): MockRequest { + return { + method: options.method || "GET", + uri: options.uri || "/", + args: options.args || {}, + headersIn: options.headersIn || {}, + headersOut: {}, + variables: options.variables || {}, + returnCode: null, + returnBody: null, + return(code: number, body: string) { + this.returnCode = code + this.returnBody = body + }, + } + } + + const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" + const LONDON = "cpslon-tst.outsystemsenterprise.com" + const SIGNAL = `Gloco-Os-Target-test=${OAPPS}` + + console.log("=".repeat(60)) + console.log("global-components.multi-os-domain-testing.js Unit Tests") + console.log("=".repeat(60)) + + // --- config.json selection --- + console.log("\nreadConfigBlobName:") + + const configRequest = (headersIn: Record, env = "test") => + createMockRequest({ + uri: "/global-components/__config", + headersIn: { Host: "polaris-qa-notprod.cps.gov.uk", ...headersIn }, + variables: { gloco_config_env: env }, + }) + + await test("serves config.json when there is no signal", async () => { + assertEqual(multiOs.readConfigBlobName(configRequest({})), "config.json", "blob") + }) + + await test("serves the variant the signal cookie names (CWA, same-origin)", async () => { + assertEqual(multiOs.readConfigBlobName(configRequest({ Cookie: `other=1; ${SIGNAL}` })), "config.oapps.json", "blob") + }) + + await test("treats an Origin of our own host as same-origin, so the cookie still decides", async () => { + const r = configRequest({ Origin: "https://polaris-qa-notprod.cps.gov.uk", Cookie: SIGNAL }) + assertEqual(multiOs.readConfigBlobName(r), "config.oapps.json", "blob") + }) + + await test("serves the variant for the OS page's own host, whatever the cookie says", async () => { + assertEqual(multiOs.readConfigBlobName(configRequest({ Origin: `https://${LONDON}`, Cookie: SIGNAL })), "config.cps-lon.json", "blob") + }) + + // Config has to match the page it runs on: auth state is per-origin. + await test("serves config.json to the environment's own OS host even when switched", async () => { + const r = configRequest({ Origin: "https://cps-tst.outsystemsenterprise.com", Cookie: SIGNAL }) + assertEqual(multiOs.readConfigBlobName(r), "config.json", "blob") + }) + + await test("ignores a signal for another environment", async () => { + assertEqual(multiOs.readConfigBlobName(configRequest({ Cookie: SIGNAL }, "dev")), "config.json", "blob") + }) + + await test("ignores a signal naming a host with no variant", async () => { + assertEqual(multiOs.readConfigBlobName(configRequest({ Cookie: "Gloco-Os-Target-test=evil.example.com" })), "config.json", "blob") + }) + + // --- C-button --- + console.log("\nlaunchTarget:") + + // Polaris's own hard-coded target for a route, from the reference copy of their nginx config. + const polarisTarget = (route: string): string => { + const conf = fs.readFileSync(path.join(CONFIG_DIR, "main", "nginx-full.conf"), "utf8") + const match = conf.match(new RegExp(`location /launch/${route} \\{\\s*return 302 ([^;]+);`)) + if (!match) throw new Error(`no Polaris location for /launch/${route}`) + return match[1]! + } + + const launchRequest = (route: string, cookie?: string) => + createMockRequest({ uri: `/launch/${route}`, headersIn: cookie ? { Cookie: cookie } : {} }) + + await test("hands back (empty) when there is no signal", async () => { + assertEqual(multiOs.launchTarget(launchRequest("cin3-proxy")), "", "target") + }) + + // Same shape and encoding as Polaris's own target — only the OS host differs. + await test("builds Polaris's own target with the OS host swapped, for every overridden route", async () => { + for (const [route, host] of [["cin2-proxy", OAPPS], ["cin3-proxy", OAPPS], ["cin4-proxy", LONDON], ["cin5-proxy", LONDON]]) { + const expected = polarisTarget(route!).split("cps-tst.outsystemsenterprise.com").join(host!) + assertEqual(multiOs.launchTarget(launchRequest(route!, `Gloco-Os-Target-test=${host}`)), expected, route!) + } + }) + + await test("hands back for a signal naming a host with no variant", async () => { + assertEqual(multiOs.launchTarget(launchRequest("cin3-proxy", "Gloco-Os-Target-test=evil.example.com")), "", "target") + }) + + await test("hands back for another environment's signal", async () => { + assertEqual(multiOs.launchTarget(launchRequest("cin3-proxy", `Gloco-Os-Target-dev=${OAPPS}`)), "", "target") + }) + + await test("never touches routes it doesn't override (raw CMS, prod)", async () => { + for (const route of ["cin3", "cms-proxy", "cin3-proxy/"]) { + assertEqual(multiOs.launchTarget(launchRequest(route, SIGNAL)), "", route) + } + }) + + // --- status --- + console.log("\nhandleStatus:") + + await test("GET lists the environment's variants and the current signal", async () => { + const r = createMockRequest({ uri: "/global-components/multi-os/target/test", headersIn: { Cookie: SIGNAL } }) + multiOs.handleStatus(r) + assertEqual(r.returnCode, 200, "status") + assertEqual( + r.returnBody, + JSON.stringify({ current: OAPPS, options: [{ variant: "oapps", host: OAPPS }, { variant: "cps-lon", host: LONDON }] }), + "body", + ) + }) + + await test("GET reports no signal for a stale host", async () => { + const r = createMockRequest({ uri: "/global-components/multi-os/target/test", headersIn: { Cookie: "Gloco-Os-Target-test=gone.example.com" } }) + multiOs.handleStatus(r) + assertEqual(JSON.parse(r.returnBody!).current, null, "current") + }) + + await test("GET gives no options for an environment without variants", async () => { + const r = createMockRequest({ uri: "/global-components/multi-os/target/prod" }) + multiOs.handleStatus(r) + assertEqual(r.returnBody, JSON.stringify({ current: null, options: [] }), "body") + }) + + await test("only GET is allowed — the set route is the only writer", async () => { + const r = createMockRequest({ method: "PUT", uri: "/global-components/multi-os/target/test" }) + multiOs.handleStatus(r) + assertEqual(r.returnCode, 405, "status") + }) + + // --- set --- + console.log("\nhandleSet:") + + const EDGE = "nonie+configurable+" + const IE = "ie+configurable+" + const setRequest = (args: Record, ieaction: string) => + createMockRequest({ uri: "/global-components/multi-os/set", args, variables: { ieaction } }) + const cookieOf = (r: MockRequest) => r.headersOut["Set-Cookie"] as string + + await test("from Edge: writes Edge's copy, then flips to IE mode to write that one", async () => { + const r = setRequest({ env: "test", host: OAPPS, return: "/global-components/test/preview/" }, EDGE) + multiOs.handleSet(r) + assert(cookieOf(r).startsWith(`Gloco-Os-Target-test=${OAPPS}; Path=/;`), `cookie: ${cookieOf(r)}`) + assert(cookieOf(r).includes("HttpOnly") && cookieOf(r).includes("Secure"), `cookie: ${cookieOf(r)}`) + assertEqual(r.headersOut["X-InternetExplorerMode"], "1", "flip to IE mode") + assertEqual(r.returnCode, 302, "status") + assertEqual( + r.returnBody, + `/global-components/multi-os/set?env=test&host=${OAPPS}&return=%2Fglobal-components%2Ftest%2Fpreview%2F&done=edge`, + "hop", + ) + }) + + await test("then in IE mode: writes IE mode's copy and returns to the preview page in Edge", async () => { + const r = setRequest({ env: "test", host: OAPPS, return: "/global-components/test/preview/", done: "edge" }, IE) + multiOs.handleSet(r) + assert(cookieOf(r).startsWith(`Gloco-Os-Target-test=${OAPPS};`), `cookie: ${cookieOf(r)}`) + assertEqual(r.headersOut["X-InternetExplorerMode"], "0", "back to Edge") + assertEqual(r.returnBody, "/global-components/test/preview/", "return") + }) + + await test("started in IE mode: writes that copy first, then flips to Edge for the other", async () => { + const first = setRequest({ env: "test", host: OAPPS, return: "/x" }, IE) + multiOs.handleSet(first) + assertEqual(first.headersOut["X-InternetExplorerMode"], "0", "flip to Edge") + assert(first.returnBody!.endsWith("&done=ie"), `hop: ${first.returnBody}`) + + const second = setRequest({ env: "test", host: OAPPS, return: "/x", done: "ie" }, EDGE) + multiOs.handleSet(second) + assertEqual(second.headersOut["X-InternetExplorerMode"], undefined, "already in Edge") + assertEqual(second.returnBody, "/x", "return") + }) + + await test("an empty host clears the signal (expired cookie), through both engines", async () => { + const r = setRequest({ env: "test", host: "", return: "/x" }, EDGE) + multiOs.handleSet(r) + assert(cookieOf(r).startsWith("Gloco-Os-Target-test=; Path=/;"), `cookie: ${cookieOf(r)}`) + assert(cookieOf(r).includes("Expires=Thu, 01 Jan 1970"), "expired") + assertEqual(r.headersOut["X-InternetExplorerMode"], "1", "still visits IE mode to clear its copy") + }) + + await test("never loops when the browser declines the flip", async () => { + const r = setRequest({ env: "test", host: OAPPS, return: "/x", done: "edge" }, EDGE) + multiOs.handleSet(r) + assertEqual(r.returnBody, "/x", "returns rather than hopping again") + }) + + await test("a site that can't flip sets the current engine's copy and returns", async () => { + const r = setRequest({ env: "test", host: OAPPS, return: "/x" }, "nonie+nonconfigurable+") + multiOs.handleSet(r) + assertEqual(r.headersOut["X-InternetExplorerMode"], undefined, "no flip") + assertEqual(r.returnBody, "/x", "return") + }) + + await test("refuses an unknown host or environment, writing nothing", async () => { + for (const args of [{ env: "test", host: "evil.example.com" }, { env: "prod", host: OAPPS }, { env: "nope", host: "" }]) { + const r = setRequest({ ...args, return: "/x" }, EDGE) + multiOs.handleSet(r) + assertEqual(r.returnCode, 400, JSON.stringify(args)) + assertEqual(r.headersOut["Set-Cookie"], undefined, "no cookie") + } + }) + + await test("only returns to paths on this host", async () => { + for (const unsafe of ["https://evil.example.com/", "//evil.example.com/", "evil", ""]) { + const r = setRequest({ env: "test", host: OAPPS, return: unsafe, done: "edge" }, IE) + multiOs.handleSet(r) + assertEqual(r.returnBody, "/", `return for ${unsafe}`) + } + }) + + // --- tables stay in step with what they describe --- + console.log("\ntables:") + + // A variant the module offers must have a config to serve, and a config + // nobody can reach is dead. + await test("OS_HOST_VARIANTS matches configuration/config...json both ways", async () => { + const fromFiles = fs + .readdirSync(REPO_CONFIGURATION_DIR) + .map((file: string) => file.match(/^config\.([^.]+)\.([^.]+)\.json$/)) + .filter((m: RegExpMatchArray | null): m is RegExpMatchArray => !!m && m[2] !== "notification") + .map(([file, env, variant]: RegExpMatchArray) => { + const text = fs.readFileSync(path.join(REPO_CONFIGURATION_DIR, file!), "utf8") + const host = (text.match(/"BANNER_TITLE_HREF": "https:\/\/([^/]+)\//) || [])[1] + return `${env}/${variant}=${host}` + }) + const fromTable = Object.entries(multiOs.OS_HOST_VARIANTS).flatMap(([env, variants]) => + Object.entries(variants).map(([variant, host]) => `${env}/${variant}=${host}`), + ) + assertEqual(JSON.stringify(fromTable.sort()), JSON.stringify(fromFiles.sort()), "variants") + }) + + // Every overridden route must exist upstream (or the hand-back goes nowhere), + // and have its own exact location in our conf. + await test("LAUNCH_ROUTES each have a Polaris prefix location and an override in our conf", async () => { + const ours = fs.readFileSync(path.join(MODULE_DIR, "global-components.multi-os-domain-testing.conf"), "utf8") + for (const route of Object.keys(multiOs.LAUNCH_ROUTES)) { + polarisTarget(route) + assert(ours.includes(`location = /launch/${route} {`), `override for ${route}`) + assert(ours.includes(`rewrite ^ /launch/${route}/ last;`), `hand-back for ${route}`) + } + }) + + console.log("\n" + "=".repeat(60)) + console.log(`Results: ${passed} passed, ${failed} failed`) + console.log("=".repeat(60)) + + process.exit(failed > 0 ? 1 : 0) +} + +;(async () => { + try { + await build() + await runTests() + } catch (err) { + console.error("Build/test failed:", (err as Error).message) + process.exit(1) + } +})() diff --git a/infra/proxy/config/global-components.vnext/global-components.vnext.conf b/infra/proxy/config/global-components.vnext/global-components.vnext.conf index db2ad2a0a..d98371de2 100644 --- a/infra/proxy/config/global-components.vnext/global-components.vnext.conf +++ b/infra/proxy/config/global-components.vnext/global-components.vnext.conf @@ -50,56 +50,3 @@ location = /global-components/validate-token { js_fetch_verify off; js_content glocovnext.handleValidateToken; } - -# --------------------------------------------------------------------------- -# OS host variants (see OS_HOST_VARIANTS in global-components.vnext.ts) -# -# config.json is served as config.json or config..json, chosen by -# readConfigBlobName from the page's Origin (OS pages) or the switch cookie -# (CWA). Exact-match locations win over the main conf's regex location for -# /global-components//..., so these take over config.json alone and leave -# every other artifact where it is. Each records its environment and hands over -# to one internal location, rather than repeating the proxy block per env. -# --------------------------------------------------------------------------- -js_set $gloco_config_blob glocovnext.readConfigBlobName; - -location = /global-components/dev/config.json { set $gloco_config_env dev; rewrite ^ /global-components/__config last; } -location = /global-components/test/config.json { set $gloco_config_env test; rewrite ^ /global-components/__config last; } -location = /global-components/uat/config.json { set $gloco_config_env uat; rewrite ^ /global-components/__config last; } -location = /global-components/prod/config.json { set $gloco_config_env prod; rewrite ^ /global-components/__config last; } - -location = /global-components/__config { - internal; - - error_page 418 = @gloco_preflight; - if ($request_method = OPTIONS) { - return 418; - } - - # As the main conf's artifact location (see its comments for the reasoning). - gzip on; - gzip_types application/json; - gzip_proxied any; - gzip_vary on; - gzip_min_length 1024; - gzip_comp_level 5; - - proxy_hide_header Access-Control-Allow-Origin; - proxy_hide_header Access-Control-Allow-Credentials; - add_header Access-Control-Allow-Origin * always; - # The body depends on who is asking, so no shared cache may reuse it across them. - add_header Vary "Origin, Cookie" always; - add_header X-Gloco-Config $gloco_config_blob always; - - resolver ${WEBSITE_DNS_SERVER} ipv6=off valid=30s; - proxy_ssl_server_name on; - proxy_ssl_verify off; - proxy_pass_request_headers off; - proxy_set_header Host ${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}; - proxy_pass https://${CPS_GLOBAL_COMPONENTS_BLOB_STORAGE_DOMAIN}/$gloco_config_env/$gloco_config_blob; -} - -# The switch itself, driven by the preview page (same-origin, so no CORS). -location ~ ^/global-components/os-target/(dev|test|uat|prod)$ { - js_content glocovnext.handleOsTarget; -} diff --git a/infra/proxy/config/global-components.vnext/global-components.vnext.ts b/infra/proxy/config/global-components.vnext/global-components.vnext.ts index 817ec1d90..597b71a47 100644 --- a/infra/proxy/config/global-components.vnext/global-components.vnext.ts +++ b/infra/proxy/config/global-components.vnext/global-components.vnext.ts @@ -91,147 +91,6 @@ async function handleValidateToken(r: NginxHTTPRequest): Promise { r.return(isValid ? 200 : 401, ""); } -// --------------------------------------------------------------------------- -// OS host variants -// -// Each environment has one OutSystems host, named literally in its -// configuration/config..json. A variant is the same config pointed at -// another OS host (configuration/config...json, deployed beside -// config.json as config..json), so named users can be switched onto -// that host while everyone else stays put. -// -// The switch is a per-environment cookie on this (polaris) host, Path=/, whose -// value is the variant's OS host. Two things read it: -// - the config route below, so the component on CWA gets the variant config -// (its links then point at the variant host); -// - Polaris's /init (appAuthRedirect), which every route into OutSystems -// passes through — C-button, CWA links, case-review — and which moves the -// handover onto the cookie's host. That lives in Polaris's nginx, and relies -// on AUTH_HANDOVER_WHITELIST to vouch for the host, hence the cookie holding -// a host rather than a variant name. -// -// Must match the variant files in configuration/ — the unit tests check both -// ways. -// --------------------------------------------------------------------------- - -const OS_HOST_VARIANTS: Record> = { - test: { - oapps: "oapps-qa-notprod.int.cps.gov.uk", - "cps-lon": "cpslon-tst.outsystemsenterprise.com", - }, -}; - -const OS_TARGET_COOKIE_PREFIX = "Gloco-Os-Target-"; -const OS_TARGET_COOKIE_LIFESPAN_MS = 365 * 24 * 60 * 60 * 1000; - -function _getCookie(r: NginxHTTPRequest, name: string): string | undefined { - const cookies = (r.headersIn["Cookie"] as string | undefined) || ""; - const parts = cookies.split(";"); - for (let i = 0; i < parts.length; i++) { - const part = parts[i].trim(); - if (part.indexOf(name + "=") === 0) { - return decodeURIComponent(part.substring(name.length + 1)); - } - } - return undefined; -} - -function _variantForHost(env: string, host: string | undefined): string | undefined { - if (!host) { - return undefined; - } - const variants = OS_HOST_VARIANTS[env] || {}; - const names = Object.keys(variants); - for (let i = 0; i < names.length; i++) { - if (variants[names[i]] === host.toLowerCase()) { - return names[i]; - } - } - return undefined; -} - -function _hostOf(url: string | undefined): string | undefined { - const match = (url || "").match(/^https?:\/\/([^/:]+)/i); - return match ? match[1].toLowerCase() : undefined; -} - -// js_set for the config.json locations: which blob to serve. -// - Cross-origin (the component or the handover on an OS page, which sends -// Origin): the config for THAT page's host, whatever the switch says. MSAL -// tokens, OS ClientVars and CMS auth are all per-origin, so config has to -// match the page it runs on. -// - Same-origin (the component on CWA, which sends the cookie but no Origin): -// the variant the switch names, if any. -// - Anything else, or a host with no variant: config.json. -function readConfigBlobName(r: NginxHTTPRequest): string { - // Set by the per-environment config.json location before it hands over to - // the shared internal location, so read it rather than the (rewritten) URI. - const env = (r.variables.gloco_config_env as string | undefined) || ""; - const originHost = _hostOf(r.headersIn["Origin"] as string | undefined); - const requestHost = ((r.headersIn["Host"] as string | undefined) || "").split(":")[0].toLowerCase(); - const isCrossOrigin = !!originHost && originHost !== requestHost; - - const variant = isCrossOrigin - ? _variantForHost(env, originHost) - : _variantForHost(env, _getCookie(r, OS_TARGET_COOKIE_PREFIX + env)); - - return variant ? "config." + variant + ".json" : "config.json"; -} - -// /global-components/os-target/ — the switch, driven by the preview page. -// GET → { current: | null, options: [{ variant, host }] } -// PUT → body { host }: switch onto that variant host (must be one of options) -// DELETE → switch back to the environment's own host -// Same-origin only (the preview page is served from this host), so no CORS. -function handleOsTarget(r: NginxHTTPRequest): void { - const env = (r.uri.match(/^\/global-components\/os-target\/([^/]+)$/) || [])[1] || ""; - const variants = OS_HOST_VARIANTS[env] || {}; - const cookieName = OS_TARGET_COOKIE_PREFIX + env; - const setCookie = (value: string, expires: Date) => { - // njs types Set-Cookie as string[]; a single string is fine at runtime - // (same cast as _setCookie in main/global-components.ts). - (r.headersOut as Record)["Set-Cookie"] = - cookieName + "=" + encodeURIComponent(value) + "; Path=/; Expires=" + expires.toUTCString() + "; Secure; HttpOnly; SameSite=Lax"; - }; - r.headersOut["Content-Type"] = "application/json"; - - if (r.method === "GET") { - const current = _getCookie(r, cookieName); - r.return( - 200, - JSON.stringify({ - current: _variantForHost(env, current) ? current : null, - options: Object.keys(variants).map(variant => ({ variant, host: variants[variant] })), - }), - ); - return; - } - - if (r.method === "PUT") { - let host: string | undefined; - try { - host = JSON.parse(r.requestText || "{}").host; - } catch (e) { - host = undefined; - } - if (!_variantForHost(env, host)) { - r.return(400, JSON.stringify({ error: "unknown OS host for " + env })); - return; - } - setCookie(host!.toLowerCase(), new Date(Date.now() + OS_TARGET_COOKIE_LIFESPAN_MS)); - r.return(200, JSON.stringify({ current: host!.toLowerCase() })); - return; - } - - if (r.method === "DELETE") { - setCookie("", new Date(0)); - r.return(200, JSON.stringify({ current: null })); - return; - } - - r.return(405, JSON.stringify({ error: "Method not allowed" })); -} - function filterSwaggerBody( r: NginxHTTPRequest, data: string, @@ -262,7 +121,4 @@ function filterSwaggerBody( export default { handleValidateToken, filterSwaggerBody, - readConfigBlobName, - handleOsTarget, - OS_HOST_VARIANTS, }; diff --git a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js index 736e996a5..3d63d775e 100755 --- a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js +++ b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.integration.test.js @@ -2,15 +2,13 @@ /** * Integration tests for global-components.vnext.conf.template * - * Tests the vnext-only functionality: swagger URL rewriting, the MDS API - * proxy (monitoring-codes), and OS host variants (config.json selection and - * the os-target switch). + * Tests the vnext-only functionality: swagger URL rewriting and the + * MDS API proxy (monitoring-codes). */ const { PROXY_BASE, assert, - assertEqual, test, fetchJson, getState, @@ -97,73 +95,6 @@ async function testSwaggerRewriting() { }) } -// ============================================================================= -// OS Host Variant Tests -// ============================================================================= - -async function testOsHostVariants() { - console.log("\nOS Host Variant Tests (config.json selection + os-target switch):") - - const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" - const CONFIG_URL = `${PROXY_BASE}/global-components/test/config.json` - const blobFileOf = response => response.headers.get("x-mock-blob-file") - - await test("serves config.json from blob storage when there is no switch", async () => { - const response = await fetch(CONFIG_URL) - assertEqual(response.status, 200, "Should return 200") - assertEqual(blobFileOf(response), "config.json", "Should serve the base config") - assert((response.headers.get("vary") || "").includes("Cookie"), "Should vary on Cookie") - }) - - await test("serves the variant named by the switch cookie", async () => { - const response = await fetch(CONFIG_URL, { - headers: { Cookie: `Gloco-Os-Target-test=${OAPPS}` }, - }) - assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") - }) - - await test("serves the variant matching an OS page's Origin", async () => { - const response = await fetch(CONFIG_URL, { - headers: { Origin: `https://${OAPPS}` }, - }) - assertEqual(blobFileOf(response), "config.oapps.json", "Should serve the variant") - assertEqual(response.headers.get("access-control-allow-origin"), "*", "Should keep CORS") - }) - - await test("leaves every other artifact to the main conf", async () => { - const response = await fetch(`${PROXY_BASE}/global-components/test/global-components.js`, { - headers: { Cookie: `Gloco-Os-Target-test=${OAPPS}` }, - }) - assertEqual(blobFileOf(response), "global-components.js", "Should be untouched") - }) - - await test("os-target: PUT sets the cookie, GET reads it back, DELETE clears it", async () => { - const put = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { - method: "PUT", - body: JSON.stringify({ host: OAPPS }), - }) - assertEqual(put.status, 200, "PUT should return 200") - const cookie = (put.headers.get("set-cookie") || "").split(";")[0] - assertEqual(cookie, `Gloco-Os-Target-test=${OAPPS}`, "Should set the switch cookie") - - const get = await fetchJson(`${PROXY_BASE}/global-components/os-target/test`, { - headers: { Cookie: cookie }, - }) - assertEqual(get.current, OAPPS, "GET should report the switch") - - const del = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { method: "DELETE" }) - assert((del.headers.get("set-cookie") || "").includes("1970"), "DELETE should expire the cookie") - }) - - await test("os-target: refuses a host that is not a variant", async () => { - const response = await fetch(`${PROXY_BASE}/global-components/os-target/test`, { - method: "PUT", - body: JSON.stringify({ host: "evil.example.com" }), - }) - assertEqual(response.status, 400, "Should return 400") - }) -} - // ============================================================================= // Main // ============================================================================= @@ -173,7 +104,6 @@ async function main() { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" await testSwaggerRewriting() - await testOsHostVariants() } module.exports = main diff --git a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts index 04058386d..407e5ed66 100644 --- a/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts +++ b/infra/proxy/config/global-components.vnext/tests/global-components.vnext.unit.test.ts @@ -47,9 +47,6 @@ interface MockRequestOptions { interface GlocoVnextModule { handleValidateToken(r: MockRequest): Promise filterSwaggerBody(r: MockRequest, data: string, flags: Record): void - readConfigBlobName(r: MockRequest): string - handleOsTarget(r: MockRequest): void - OS_HOST_VARIANTS: Record> } // Bundle both modules @@ -234,136 +231,6 @@ async function runTests(): Promise { ) }) - // --- OS host variants --- - console.log("\nreadConfigBlobName:") - - const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" - const configRequest = (headersIn: Record, env = "test") => - createMockRequest({ - uri: "/global-components/__config", - headersIn: { Host: "polaris-qa-notprod.cps.gov.uk", ...headersIn }, - variables: { gloco_config_env: env }, - }) - - await test("serves config.json when there is no switch", async () => { - assertEqual(glocovnext.readConfigBlobName(configRequest({})), "config.json", "blob") - }) - - await test("serves the variant the switch cookie names (CWA, same-origin)", async () => { - const r = configRequest({ Cookie: `other=1; Gloco-Os-Target-test=${OAPPS}` }) - assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") - }) - - await test("treats an Origin of our own host as same-origin, so the cookie still decides", async () => { - const r = configRequest({ Origin: "https://polaris-qa-notprod.cps.gov.uk", Cookie: `Gloco-Os-Target-test=${OAPPS}` }) - assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") - }) - - await test("serves the variant for the OS page's own host, whatever the cookie says", async () => { - const r = configRequest({ Origin: `https://${OAPPS}` }) - assertEqual(glocovnext.readConfigBlobName(r), "config.oapps.json", "blob") - }) - - // Config has to match the page it runs on: auth state is per-origin. - await test("serves config.json to the environment's own OS host even when switched", async () => { - const r = configRequest({ Origin: "https://cps-tst.outsystemsenterprise.com", Cookie: `Gloco-Os-Target-test=${OAPPS}` }) - assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") - }) - - await test("ignores a switch for another environment", async () => { - const r = configRequest({ Cookie: `Gloco-Os-Target-test=${OAPPS}` }, "dev") - assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") - }) - - await test("ignores a switch naming a host with no variant", async () => { - const r = configRequest({ Cookie: "Gloco-Os-Target-test=evil.example.com" }) - assertEqual(glocovnext.readConfigBlobName(r), "config.json", "blob") - }) - - console.log("\nhandleOsTarget:") - - const osTargetRequest = (method: string, options: MockRequestOptions = {}) => - createMockRequest({ method, uri: "/global-components/os-target/test", ...options }) - - await test("GET lists the environment's variants and the current switch", async () => { - const r = osTargetRequest("GET", { headersIn: { Cookie: `Gloco-Os-Target-test=${OAPPS}` } }) - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 200, "status") - assertEqual( - r.returnBody, - JSON.stringify({ - current: OAPPS, - options: [ - { variant: "oapps", host: OAPPS }, - { variant: "cps-lon", host: "cpslon-tst.outsystemsenterprise.com" }, - ], - }), - "body", - ) - }) - - await test("GET reports no switch for a stale host", async () => { - const r = osTargetRequest("GET", { headersIn: { Cookie: "Gloco-Os-Target-test=gone.example.com" } }) - glocovnext.handleOsTarget(r) - assertEqual(JSON.parse(r.returnBody!).current, null, "current") - }) - - await test("PUT sets a Path=/ cookie for a known host", async () => { - const r = osTargetRequest("PUT", { requestText: JSON.stringify({ host: OAPPS }) }) - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 200, "status") - const cookie = r.headersOut["Set-Cookie"] as string - assert(cookie.startsWith(`Gloco-Os-Target-test=${OAPPS}; Path=/;`), `cookie: ${cookie}`) - assert(cookie.includes("Secure") && cookie.includes("HttpOnly") && cookie.includes("SameSite=Lax"), `cookie: ${cookie}`) - }) - - await test("PUT refuses a host that is not a variant of this environment", async () => { - for (const requestText of [JSON.stringify({ host: "evil.example.com" }), "not json", ""]) { - const r = osTargetRequest("PUT", { requestText }) - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 400, `status for ${requestText}`) - assertEqual(r.headersOut["Set-Cookie"], undefined, "no cookie") - } - }) - - await test("PUT refuses for an environment with no variants", async () => { - const r = createMockRequest({ method: "PUT", uri: "/global-components/os-target/prod", requestText: JSON.stringify({ host: OAPPS }) }) - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 400, "status") - }) - - await test("DELETE expires the cookie", async () => { - const r = osTargetRequest("DELETE") - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 200, "status") - assert((r.headersOut["Set-Cookie"] as string).includes("Expires=Thu, 01 Jan 1970"), "expired") - }) - - await test("other methods are refused", async () => { - const r = osTargetRequest("POST") - glocovnext.handleOsTarget(r) - assertEqual(r.returnCode, 405, "status") - }) - - // The table and the files are two halves of one fact: a variant the proxy - // offers must have a config to serve, and a config nobody can reach is dead. - await test("OS_HOST_VARIANTS matches configuration/config...json both ways", async () => { - const configDir = path.join(__dirname, "..", "..", "..", "..", "..", "configuration") - const files = fs - .readdirSync(configDir) - .map((file: string) => file.match(/^config\.([^.]+)\.([^.]+)\.json$/)) - .filter((m: RegExpMatchArray | null): m is RegExpMatchArray => !!m && m[2] !== "notification") - const fromFiles = files.map(([file, env, variant]: RegExpMatchArray) => { - const text = fs.readFileSync(path.join(configDir, file), "utf8") - const host = (text.match(/"BANNER_TITLE_HREF": "https:\/\/([^/]+)\//) || [])[1] - return `${env}/${variant}=${host}` - }) - const fromTable = Object.entries(glocovnext.OS_HOST_VARIANTS).flatMap(([env, variants]) => - Object.entries(variants).map(([variant, host]) => `${env}/${variant}=${host}`), - ) - assertEqual(JSON.stringify(fromTable.sort()), JSON.stringify(fromFiles.sort()), "variants") - }) - // Summary console.log("\n" + "=".repeat(60)) console.log(`Results: ${passed} passed, ${failed} failed`) diff --git a/infra/proxy/config/main/nginx.js b/infra/proxy/config/main/nginx.js index 383de9b37..5d533863c 100644 --- a/infra/proxy/config/main/nginx.js +++ b/infra/proxy/config/main/nginx.js @@ -146,67 +146,6 @@ function _shimBigIpCookies(args) { return clonedArgs } -// OS host switch (global-components multi-targeting). Named users can be switched -// onto another OutSystems host than the rest of their environment (e.g. the oapps -// proxy, or the London tenant) via a per-environment cookie on this host, set from -// the global-components preview page: -// Gloco-Os-Target-= -// Every route into OutSystems — the C-button, the menu's outbound links, the -// case-review redirect — reaches here with r pointing at the OS auth handover, so -// this is the one place that moves the user onto the switched host. It runs before -// anything is written to OS-origin storage, which is per-host. -// -// The environment comes from the handover's own src param -// (.../global-components//auth-handover.js): dev and test share this host, so -// the cookie has to be per environment. Both the handover host and the host of its -// final destination (the nested r) are swapped, as the handover only returns users -// to its own origin. -// -// Returns the rewritten URL, or the original one when there's no switch or the URL -// isn't an OS handover. The caller only uses a rewrite that AUTH_HANDOVER_WHITELIST -// accepts, so a stale or tampered cookie falls back to the original target rather -// than a 403. -const OS_TARGET_COOKIE_PREFIX = "Gloco-Os-Target-" - -function _escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -function _toOsTarget(r, url) { - const match = /^(https:\/\/)([^/?#]+)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(url || "") - if (!match) { - return url - } - // Indexed rather than destructured: njs has no destructuring assignment. - const scheme = match[1] - const fromHost = match[2] - const path = match[3] - const query = match[4] || "" - const hash = match[5] || "" - if (!/\/auth-handover\.html$/i.test(path)) { - return url - } - - const src = qs.parse(query.replace(/^\?/, ""))["src"] || "" - const env = (/\/global-components\/([^/]+)\/auth-handover\.js/.exec(src) || [])[1] - if (!env) { - return url - } - - const toHost = _maybeDecodeURIComponent(_getCookieValue(r, OS_TARGET_COOKIE_PREFIX + env)).toLowerCase() - if (!/^[a-z0-9.-]+$/.test(toHost) || toHost === fromHost.toLowerCase()) { - return url - } - - // The nested final destination, in the r param: percent-encoded when it came - // from a URL builder, plain when hand-written. - const nestedDestination = new RegExp( - "([?&]r=https(?:%3A%2F%2F|://))" + _escapeRegExp(fromHost) + "(?=%2F|%3F|%23|/|&|$)", - "i" - ) - return scheme + toHost + path + query.replace(nestedDestination, "$1" + toHost) + hash -} - function appAuthRedirect(r) { setSessionHintCookie(r) @@ -214,16 +153,10 @@ function appAuthRedirect(r) { args = _shimBigIpCookies(args) const whitelistedUrls = process.env.AUTH_HANDOVER_WHITELIST ?? "" - const isWhitelistedUrl = (url) => - whitelistedUrls.split(",").some((prefix) => url.startsWith(prefix)) - - const requestedUrl = args["r"] - const osTargetUrl = _toOsTarget(r, requestedUrl) - const redirectUrl = - osTargetUrl !== requestedUrl && isWhitelistedUrl(osTargetUrl) - ? osTargetUrl - : requestedUrl - const isWhitelisted = isWhitelistedUrl(redirectUrl) + const redirectUrl = args["r"] + const isWhitelisted = whitelistedUrls + .split(",") + .some((url) => redirectUrl.startsWith(url)) if (isWhitelisted) { _redirectToAbsoluteUrl( diff --git a/infra/proxy/config/main/tests/nginx.unit.test.ts b/infra/proxy/config/main/tests/nginx.unit.test.ts index 8f753b0ba..3f477abdb 100644 --- a/infra/proxy/config/main/tests/nginx.unit.test.ts +++ b/infra/proxy/config/main/tests/nginx.unit.test.ts @@ -180,99 +180,6 @@ async function runTests(): Promise { ) }) - // --- OS host switch (Gloco-Os-Target-) --- - const TEST_HANDOVER = - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" + - "?src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk%2Fglobal-components%2Ftest%2Fauth-handover.js" + - "&stage=os-cookie-return" + - "&r=https%3A%2F%2Fcps-tst.outsystemsenterprise.com%2Fcasework_blocks%2Fhome%3FIsFromCMS%3DTrue" - const OAPPS = "oapps-qa-notprod.int.cps.gov.uk" - const BOTH_HANDOVERS = - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html," + - `https://${OAPPS}/Casework_Patterns/auth-handover.html` - - const initRequest = (r: string, cookieHeader?: string) => - createMockRequest({ - args: { r, cookie: "session=abc123" }, - headersIn: { - "X-Forwarded-Proto": "https", - Host: "polaris-qa-notprod.cps.gov.uk", - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - }) - - await test("OS switch: leaves the handover alone when there is no switch cookie", async () => { - process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS - const r = initRequest(TEST_HANDOVER) - nginx.appAuthRedirect(r) - assertEqual(r.returnCode, 302, "Should redirect") - assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) - }) - - await test("OS switch: moves the handover and its destination onto the switched host", async () => { - process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS - const r = initRequest(TEST_HANDOVER, `other=1; Gloco-Os-Target-test=${OAPPS}`) - nginx.appAuthRedirect(r) - assertEqual(r.returnCode, 302, "Should redirect") - assert( - r.returnBody!.startsWith(`https://${OAPPS}/Casework_Patterns/auth-handover.html?`), - `Should target the switched host's handover, got: ${r.returnBody}` - ) - assert( - r.returnBody!.includes(`&r=https%3A%2F%2F${OAPPS}%2Fcasework_blocks%2Fhome`), - `Should move the nested destination too, got: ${r.returnBody}` - ) - assert( - r.returnBody!.includes("src=https%3A%2F%2Fpolaris-qa-notprod.cps.gov.uk"), - `Should leave the polaris src alone, got: ${r.returnBody}` - ) - assert(r.returnBody!.includes("cc=session%3Dabc123"), `Should still append cc, got: ${r.returnBody}`) - }) - - await test("OS switch: also moves a plain (unencoded) nested destination", async () => { - process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS - const handover = - "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" + - "?src=https://polaris-qa-notprod.cps.gov.uk/global-components/test/auth-handover.js" + - "&stage=os-cookie-return&r=https://cps-tst.outsystemsenterprise.com/CaseReview/LandingPage" - const r = initRequest(handover, `Gloco-Os-Target-test=${OAPPS}`) - nginx.appAuthRedirect(r) - assert( - r.returnBody!.includes(`&r=https://${OAPPS}/CaseReview/LandingPage`), - `Should move the nested destination, got: ${r.returnBody}` - ) - }) - - await test("OS switch: falls back to the original handover when the switched one isn't whitelisted", async () => { - process.env.AUTH_HANDOVER_WHITELIST = "https://cps-tst.outsystemsenterprise.com/Casework_Patterns/auth-handover.html" - const r = initRequest(TEST_HANDOVER, "Gloco-Os-Target-test=stale.example.com") - nginx.appAuthRedirect(r) - assertEqual(r.returnCode, 302, "Should still redirect, not 403") - assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) - }) - - await test("OS switch: ignores a switch for another environment", async () => { - process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS - const r = initRequest(TEST_HANDOVER, `Gloco-Os-Target-dev=${OAPPS}`) - nginx.appAuthRedirect(r) - assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) - }) - - await test("OS switch: ignores targets that are not an OS handover", async () => { - process.env.AUTH_HANDOVER_WHITELIST = "/auth-refresh-inbound" - const r = initRequest("/auth-refresh-inbound", `Gloco-Os-Target-test=${OAPPS}`) - nginx.appAuthRedirect(r) - assertEqual(r.returnCode, 302, "Should redirect") - assert(r.returnBody!.includes("/auth-refresh-inbound?cc="), `Should be unchanged, got: ${r.returnBody}`) - }) - - await test("OS switch: ignores a cookie value that isn't a hostname", async () => { - process.env.AUTH_HANDOVER_WHITELIST = BOTH_HANDOVERS - const r = initRequest(TEST_HANDOVER, "Gloco-Os-Target-test=evil.example.com%2Fpath") - nginx.appAuthRedirect(r) - assert(r.returnBody!.startsWith(TEST_HANDOVER), `Should be the original handover, got: ${r.returnBody}`) - }) - await test("sets Cms-Session-Hint cookie with correct attributes", async () => { process.env.AUTH_HANDOVER_WHITELIST = "/auth-refresh-inbound" const r = createMockRequest({ diff --git a/infra/proxy/deploy/deploy.sh b/infra/proxy/deploy/deploy.sh index e569432f5..78b4a8bb8 100755 --- a/infra/proxy/deploy/deploy.sh +++ b/infra/proxy/deploy/deploy.sh @@ -81,6 +81,9 @@ CONTENT_DIR="${HOME}/.gc-deploy-content" FILES_TO_DEPLOY=( "global-components.vnext.conf.template" "global-components.vnext.js" + # Temporary multi-OS-domain testing module (FCT2-22132) — delete with the module. + "global-components.multi-os-domain-testing.conf.template" + "global-components.multi-os-domain-testing.js" ) # App settings to deploy (vnext-specific only) diff --git a/infra/proxy/deploy/rollback.sh b/infra/proxy/deploy/rollback.sh index 465ff9a47..2a81300bd 100755 --- a/infra/proxy/deploy/rollback.sh +++ b/infra/proxy/deploy/rollback.sh @@ -115,6 +115,9 @@ echo -e "\n${YELLOW}Backing up current state before rollback to: $PRE_ROLLBACK_D FILES_TO_BACKUP=( "global-components.vnext.conf.template" "global-components.vnext.js" + # Temporary multi-OS-domain testing module (FCT2-22132) — delete with the module. + "global-components.multi-os-domain-testing.conf.template" + "global-components.multi-os-domain-testing.js" ) for file in "${FILES_TO_BACKUP[@]}"; do diff --git a/infra/proxy/docker/docker-compose.multi-os-domain-testing.yml b/infra/proxy/docker/docker-compose.multi-os-domain-testing.yml new file mode 100644 index 000000000..bb754525d --- /dev/null +++ b/infra/proxy/docker/docker-compose.multi-os-domain-testing.yml @@ -0,0 +1,12 @@ +# Multi-OS-domain testing layer override (FCT2-22132 — temporary module) +# Use: docker-compose -f docker-compose.yml -f docker-compose.multi-os-domain-testing.yml up +# +# Mounts the module's conf + compiled JS, plus test-only stand-ins for Polaris's +# /launch routes so the overrides' hand-back can be exercised end to end. + +services: + nginx: + volumes: + - ../config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf:/etc/nginx/templates/global-components.multi-os-domain-testing.conf.template:ro + - ../dist/global-components.multi-os-domain-testing.js:/etc/nginx/templates/global-components.multi-os-domain-testing.js:ro + - ./test-only.polaris-launch.conf:/etc/nginx/global-components.test-polaris-launch.conf:ro diff --git a/infra/proxy/docker/test-only.polaris-launch.conf b/infra/proxy/docker/test-only.polaris-launch.conf new file mode 100644 index 000000000..2455e0add --- /dev/null +++ b/infra/proxy/docker/test-only.polaris-launch.conf @@ -0,0 +1,18 @@ +# Test-only stand-ins for Polaris's own C-button launch routes, which live in +# Polaris's nginx config (see the reference copy, config/main/nginx-full.conf), +# not in this repo's docker stack. Same shape as theirs: PREFIX locations that +# return a fixed redirect. +# +# Mounted only for the multi-os-domain-testing layer, under a name matching the +# `global-components*.conf` include glob, so the integration tests can prove that +# the module's EXACT-match overrides hand a request without the signal cookie +# back to these blocks untouched — and leave routes they don't override alone. +# The targets are markers, not real URLs: the tests only compare Location headers. + +location /launch/cin3-proxy { + return 302 https://polaris-qa-notprod.cps.gov.uk/polaris?r=STAND-IN-cin3-proxy; +} + +location /launch/cin3 { + return 302 https://cin3.cps.gov.uk/polaris?r=STAND-IN-cin3; +} diff --git a/infra/proxy/package.json b/infra/proxy/package.json index 626ceb9ae..4591e908a 100644 --- a/infra/proxy/package.json +++ b/infra/proxy/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "./scripts/build.sh", "build:watch": "tsc --watch", - "test": "pnpm exec ts-node --project tsconfig.test.json config/main/tests/global-components.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/global-components.vnext/tests/global-components.vnext.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/global-components.case-locking/tests/global-components.case-locking.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/main/tests/nginx.unit.test.ts", + "test": "pnpm exec ts-node --project tsconfig.test.json config/main/tests/global-components.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/global-components.vnext/tests/global-components.vnext.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/global-components.case-locking/tests/global-components.case-locking.unit.test.ts && pnpm exec ts-node --project tsconfig.test.json config/main/tests/nginx.unit.test.ts", "test:integration": "pnpm build && ./run-tests.sh" }, "devDependencies": { diff --git a/infra/proxy/run-tests.sh b/infra/proxy/run-tests.sh index 2c10e75fc..a017c34bb 100755 --- a/infra/proxy/run-tests.sh +++ b/infra/proxy/run-tests.sh @@ -38,7 +38,7 @@ wait_for_proxy() { # Stop any running stack stop_stack() { cd "$DOCKER_DIR" - docker compose -f docker-compose.yml -f docker-compose.vnext.yml -f docker-compose.cms-auth-v2.yml down -t 2 2>/dev/null || true + docker compose -f docker-compose.yml -f docker-compose.vnext.yml -f docker-compose.cms-auth-v2.yml -f docker-compose.multi-os-domain-testing.yml down -t 2 2>/dev/null || true } # Run a test layer @@ -131,6 +131,17 @@ else TOTAL_FAILED=$((TOTAL_FAILED + 1)) fi +# Layer 5: Multi-OS-domain testing (temporary module, FCT2-22132) +if run_layer "multi-os-domain-testing" \ + "-f docker-compose.yml -f docker-compose.multi-os-domain-testing.yml" \ + "/health" \ + "$SCRIPT_DIR/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js"; then + LAYER_RESULTS="${LAYER_RESULTS} ${GREEN}✓${NC} multi-os-domain-testing\n" +else + LAYER_RESULTS="${LAYER_RESULTS} ${RED}✗${NC} multi-os-domain-testing\n" + TOTAL_FAILED=$((TOTAL_FAILED + 1)) +fi + # Summary echo "" echo "==============================================" diff --git a/infra/proxy/scripts/build.sh b/infra/proxy/scripts/build.sh index 7ab6b896f..c762a4e4a 100755 --- a/infra/proxy/scripts/build.sh +++ b/infra/proxy/scripts/build.sh @@ -28,6 +28,8 @@ echo "Copying config files..." cp "$PROXY_DIR/config/main/nginx.conf" "$DIST_DIR/nginx.conf.template" cp "$PROXY_DIR/config/main/global-components.conf" "$DIST_DIR/global-components.conf.template" cp "$PROXY_DIR/config/global-components.vnext/global-components.vnext.conf" "$DIST_DIR/global-components.vnext.conf.template" +# Temporary multi-OS-domain testing module (FCT2-22132) — delete with the module. +cp "$PROXY_DIR/config/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.conf" "$DIST_DIR/global-components.multi-os-domain-testing.conf.template" # NOTE: the v1 spike variants (cms-auth, cms-ping, cms-proxy-no-logout, spike) are # archived under global-components.cms-auth-v2/previous/ for REFERENCE ONLY. They are # deliberately NOT built or packaged — tsconfig excludes config/**/previous/**, so they @@ -44,6 +46,7 @@ cp "$PROXY_DIR/config/global-components.case-locking/global-components.case-lock echo "Flattening compiled JS files..." mv "$DIST_DIR/main/global-components.js" "$DIST_DIR/global-components.js" mv "$DIST_DIR/global-components.vnext/global-components.vnext.js" "$DIST_DIR/global-components.vnext.js" +mv "$DIST_DIR/global-components.multi-os-domain-testing/global-components.multi-os-domain-testing.js" "$DIST_DIR/global-components.multi-os-domain-testing.js" mv "$DIST_DIR/global-components.cms-auth-v2/global-components.cms-auth-v2.js" "$DIST_DIR/global-components.cms-auth-v2.js" mv "$DIST_DIR/global-components.case-locking/global-components.case-locking.js" "$DIST_DIR/global-components.case-locking.js" # polaris-non-ddei: another out-of-band POC (like cms-auth-v2) — flatten its @@ -53,6 +56,7 @@ mv "$DIST_DIR/global-components.polaris-non-ddei/global-components.polaris-non-d # Remove empty directories rmdir "$DIST_DIR/main" 2>/dev/null || true rmdir "$DIST_DIR/global-components.vnext" 2>/dev/null || true +rmdir "$DIST_DIR/global-components.multi-os-domain-testing" 2>/dev/null || true rmdir "$DIST_DIR/global-components.cms-auth-v2" 2>/dev/null || true rmdir "$DIST_DIR/global-components.case-locking" 2>/dev/null || true rmdir "$DIST_DIR/global-components.polaris-non-ddei" 2>/dev/null || true diff --git a/packages/cps-global-preview/src/App.tsx b/packages/cps-global-preview/src/App.tsx index deb237deb..fc97a3aae 100644 --- a/packages/cps-global-preview/src/App.tsx +++ b/packages/cps-global-preview/src/App.tsx @@ -7,10 +7,13 @@ const AUTH_HINT_ENDPOINT = "/global-components/state/auth-hint"; const ENV_MATCH = window.location.pathname.match(/\/global-components\/([^/]+)\//); const ENV = ENV_MATCH?.[1] ?? "test"; const NOTIFICATIONS_ENDPOINT = `/global-components/${ENV}/notification.json`; -// The per-environment OS host switch, owned by the proxy (see OS_HOST_VARIANTS -// in infra/proxy/config/global-components.vnext). It lives in its own cookie -// rather than preview state because Polaris's /init has to read it too. -const OS_TARGET_ENDPOINT = `/global-components/os-target/${ENV}`; +// The per-environment OS host switch, owned by the proxy's temporary +// multi-os-domain-testing module (infra/proxy/config/global-components.multi-os-domain-testing). +// It lives in its own cookie rather than preview state because the proxy reads it +// server-side, in both Edge and IE mode — so switching is a NAVIGATION through +// the set route, which visits both engines to write both cookie copies. +const OS_TARGET_STATUS_ENDPOINT = `/global-components/multi-os/target/${ENV}`; +const OS_TARGET_SET_ENDPOINT = "/global-components/multi-os/set"; type OsTarget = { current: string | null; @@ -257,7 +260,7 @@ export function App() { const loadOsTarget = useCallback(async () => { try { - const response = await fetch(OS_TARGET_ENDPOINT, { credentials: "include" }); + const response = await fetch(OS_TARGET_STATUS_ENDPOINT, { credentials: "include" }); if (response.ok) { setOsTarget(await response.json()); } @@ -270,22 +273,11 @@ export function App() { loadOsTarget(); }, [loadOsTarget]); - const handleOsTargetChange = async (host: string) => { - try { - const response = await fetch(OS_TARGET_ENDPOINT, { - method: host ? "PUT" : "DELETE", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: host ? JSON.stringify({ host }) : undefined, - }); - if (!response.ok) { - throw new Error("Failed to switch OutSystems host"); - } - setOsTarget(osTarget && { ...osTarget, current: host || null }); - showStatus("OutSystems host switched — takes effect on your next page load or C-button press", "success"); - } catch (err) { - showStatus(err instanceof Error ? err.message : "Unknown error", "error"); - } + // Leaves the page: the set route writes the cookie in Edge, flips the tab to IE + // mode to write IE mode's copy, then brings the tab back here. + const handleOsTargetChange = (host: string) => { + const query = new URLSearchParams({ env: ENV, host, return: window.location.pathname + window.location.search }); + window.location.assign(`${OS_TARGET_SET_ENDPOINT}?${query}`); }; const handleEnabledChange = (checked: boolean) => { @@ -825,9 +817,10 @@ export function App() {

OutSystems host

- Which OutSystems host the C-button and the menu send you to in this - environment. Pages you open directly on either host keep working as - they are. + Which OutSystems host the proxied CMS C-button and the menu send you + to in this environment. Changing it briefly reloads this page (it has + to visit both Edge and IE mode to set the choice in each). Pages you + open directly on either host keep working as they are.

{[{ variant: "", host: "" }, ...osTarget.options].map(({ variant, host }) => ( From 4e536d1b893850e150a68339eaf643afccb25a9e Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Tue, 29 Sep 2026 17:09:35 +0100 Subject: [PATCH 7/8] #FCT2-22132 - feature complete on os domain switching --- ...obal-components.multi-os-domain-testing.integration.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js index a2cf84ffc..bd51b281f 100644 --- a/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js +++ b/infra/proxy/config/global-components.multi-os-domain-testing/tests/global-components.multi-os-domain-testing.integration.test.js @@ -122,7 +122,8 @@ async function testSwitch() { const { location, response } = await redirectOf(`${setPath(OAPPS)}&done=edge`, IE_MODE) assertEqual(response.headers.get("x-internetexplorermode"), "0", "Should flip back to Edge") assert((response.headers.get("set-cookie") || "").startsWith(SIGNAL), "Should set the IE-mode copy") - assertEqual(location, "/global-components/test/preview/", "Should return to the preview page") + // nginx absolutises relative redirects (absolute_redirect on), so compare the path only. + assertEqual(new URL(location, PROXY_BASE).pathname, "/global-components/test/preview/", "Should return to the preview page") }) await test("set with no host clears the signal", async () => { From 118d774d79127070d7e013ef17f2ca2f5e255dec Mon Sep 17 00:00:00 2001 From: Stef Stachow Date: Tue, 29 Sep 2026 17:43:47 +0100 Subject: [PATCH 8/8] #FCT2-22132 - feature complete on os domain switching --- packages/cps-global-preview/src/App.tsx | 45 ++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/cps-global-preview/src/App.tsx b/packages/cps-global-preview/src/App.tsx index fc97a3aae..472a1937a 100644 --- a/packages/cps-global-preview/src/App.tsx +++ b/packages/cps-global-preview/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef, type ReactNode } from "react"; import type { AuthHint, Notification, Preview } from "cps-global-configuration"; const STATE_ENDPOINT = "/global-components/state/preview"; @@ -161,6 +161,16 @@ const FEATURES: Feature[] = [ type FeatureKey = Feature["key"]; +// A section heading that is its own #anchor, so sections can be linked to directly. +// Ids are prefixed "section-" to stay clear of the form inputs' ids. +const SectionHeading = ({ id, className, children }: { id: string; className: string; children: ReactNode }) => ( +

+ + {children} + +

+); + type StatusType = "info" | "error" | "success"; export function App() { @@ -175,6 +185,7 @@ export function App() { const [authHint, setAuthHint] = useState(null); const [sidInput, setSidInput] = useState(""); const [osTarget, setOsTarget] = useState(null); + const scrolledToHash = useRef(false); const showStatus = useCallback((message: string, type: StatusType) => { setStatus({ message, type }); @@ -273,10 +284,24 @@ export function App() { loadOsTarget(); }, [loadOsTarget]); + // Sections render after async loads (the OutSystems host one only once its + // status arrives), so the browser's own jump to the #anchor on load misses them. + // Jump once, as soon as the target exists. + useEffect(() => { + if (scrolledToHash.current || !window.location.hash) { + return; + } + const target = document.getElementById(decodeURIComponent(window.location.hash.slice(1))); + if (target) { + scrolledToHash.current = true; + target.scrollIntoView(); + } + }, [loading, osTarget, notificationsResult, authHint]); + // Leaves the page: the set route writes the cookie in Edge, flips the tab to IE // mode to write IE mode's copy, then brings the tab back here. const handleOsTargetChange = (host: string) => { - const query = new URLSearchParams({ env: ENV, host, return: window.location.pathname + window.location.search }); + const query = new URLSearchParams({ env: ENV, host, return: window.location.pathname + window.location.search + "#section-os-host" }); window.location.assign(`${OS_TARGET_SET_ENDPOINT}?${query}`); }; @@ -444,7 +469,7 @@ export function App() {
-

Features

+ Features
{FEATURES.map( @@ -612,7 +637,7 @@ export function App() {
-

Notifications

+ Notifications
@@ -653,7 +678,11 @@ export function App() { Clear dismissed notifications -

Deployed notifications

+

+ + Deployed notifications + +

Read-only view of notification.json loaded from blob storage for the {ENV} environment. Edit the source file to change @@ -755,7 +784,7 @@ export function App() {

-

Auth hint

+ Auth hint

Overwrite the lastKnownSid stored against your auth-hint cookie. @@ -814,7 +843,7 @@ export function App() {

-

OutSystems host

+ OutSystems host

Which OutSystems host the proxied CMS C-button and the menu send you @@ -847,7 +876,7 @@ export function App() {

-

Override mode

+ Override mode