diff --git a/TODO.md b/TODO.md index 4307a5923..d414151ae 100644 --- a/TODO.md +++ b/TODO.md @@ -1,90 +1,203 @@ # 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. 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 + +## 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. +- [ ] 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`. +- [ ] 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, in BOTH Edge and IE mode's stores: `Path=/`, + `SameSite=Lax`, `HttpOnly`, and its value is the OS 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 + 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 +205,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. 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..bd51b281f --- /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,169 @@ +#!/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") + // 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 () => { + 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 }) => (