From d07aa2f64f9cbffe0da9e84ad15143fdf8315c6c Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 4 Sep 2026 17:24:41 +0200 Subject: [PATCH] test(portals): assert each portal proxies only its own endpoint group (refs #166) The four portal proxy configs are near-identical, so a copy-paste slip is cheap to introduce and expensive to find: proxying another portal's endpoint group hands a browser an endpoint its token is not for, and the failure surfaces as a 401 three services away. Asserts each portal proxies exactly its own groups to the BFF and keeps the SPA fallback for Angular's client-side routes. Red: the Caddyfiles it reads do not exist yet. --- Makefile | 1 + infra/test_portal_caddyfiles.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 infra/test_portal_caddyfiles.py diff --git a/Makefile b/Makefile index 75d7163..fed9828 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ build: # TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally. unit: dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults + python3 infra/test_portal_caddyfiles.py ## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` diff --git a/infra/test_portal_caddyfiles.py b/infra/test_portal_caddyfiles.py new file mode 100644 index 0000000..b72172f --- /dev/null +++ b/infra/test_portal_caddyfiles.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Self-check for the portals' Caddyfiles — stdlib asserts, no framework. + +Run: python3 infra/test_portal_caddyfiles.py (also runs in `make unit`). + +Each portal serves its Angular app and reverse-proxies *its own* BFF endpoint group +same-origin, so the browser never sees CORS and the DigiD token rides along (ADR-0010). +The four files are near-identical, which makes a copy-paste slip cheap to introduce and +expensive to find: proxying another portal's group hands a behandelaar's browser an +endpoint its token isn't for, and the failure shows up as a 401 three services away. + +What is asserted per portal: it proxies exactly its own groups to the BFF service, and it +falls back to index.html so Angular's client-side routes survive a deep link / refresh. +""" +import os +import re + +APPS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "apps") + +# The self-service portal also renders the public register (S-09), so it proxies both. +EXPECTED = { + "self-service": {"/self-service/*", "/openbaar/*"}, + "openbaar": {"/openbaar/*"}, + "behandel": {"/behandel/*"}, + "beheer": {"/beheer/*"}, +} +ALL_GROUPS = {g for groups in EXPECTED.values() for g in groups} + + +def caddyfile(app): + with open(os.path.join(APPS, app, "Caddyfile")) as fh: + return fh.read() + + +def proxied_groups(text): + """The path groups routed to the BFF: `handle { reverse_proxy bff:8080 }`.""" + return { + m.group(1) + for m in re.finditer(r"handle\s+(\S+)\s*\{[^}]*reverse_proxy\s+bff:8080", text) + } + + +def test_each_portal_proxies_exactly_its_own_endpoint_groups(): + for app, expected in EXPECTED.items(): + got = proxied_groups(caddyfile(app)) + assert got == expected, f"{app}: proxies {got or '{}'}, expected {expected}" + + +def test_no_portal_proxies_another_portals_group(): + for app, expected in EXPECTED.items(): + strays = proxied_groups(caddyfile(app)) & (ALL_GROUPS - expected) + assert not strays, f"{app}: proxies another portal's group {strays}" + + +def test_every_portal_falls_back_to_index_html(): + """Angular routes client-side: an unknown path must serve the app, not a 404.""" + for app in EXPECTED: + text = caddyfile(app) + assert "try_files {path} /index.html" in text, f"{app}: no SPA fallback" + assert "file_server" in text, f"{app}: nothing serves the built app" + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f" ok {name}") + print("portal Caddyfile self-check passed")