diff --git a/plugins/lookup/roles_with_service.py b/plugins/lookup/roles_with_service.py index 269457978e..3ad7a2e9c4 100644 --- a/plugins/lookup/roles_with_service.py +++ b/plugins/lookup/roles_with_service.py @@ -1,23 +1,8 @@ -"""Lookup plugin: enumerate consumer roles for a given service. +"""Enumerate consumer roles for a given service. -Usage:: - - {{ lookup('roles_with_service', 'dashboard') | to_json }} - -Returns a sorted list of dicts ``[{id, canonical_domain}, …]``, one per -role whose merged applications config declares ``services..{ -enabled, shared}`` as truthy AND that exposes a canonical domain. - -Used by the SPOT-owner specs (web-app-{dashboard, prometheus, matomo}) -to render a per-consumer manifest into ``templates/playwright.env.j2``. -The provider's ``files/playwright/playwright.spec.js`` then parameterises one -assertion per consumer over that manifest, owning the cross-service -reachability assertion in one place. - -The lookup reuses ``get_merged_applications`` (the same helper -``lookup('applications', ...)`` uses) so the data is already rendered -against the inventory's ``group_names`` at runtime — no template-side -``for`` loops or filter logic required. +Returns ``[{id, canonical_domain, canonical_url}, …]`` for every role +whose merged applications config declares +``services..{enabled, shared}`` as truthy. """ from __future__ import annotations @@ -25,6 +10,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from utils.cache.applications import get_merged_applications @@ -74,6 +60,10 @@ class LookupModule(LookupBase): templar=getattr(self, "_templar", None), ) + tls_lookup = lookup_loader.get( + "tls", loader=self._loader, templar=self._templar + ) + results: list[dict[str, str]] = [] for role_id, app_config in applications.items(): if not isinstance(app_config, dict): @@ -91,7 +81,15 @@ class LookupModule(LookupBase): canonical = _resolve_canonical_domain(app_config) if not canonical: continue - results.append({"id": str(role_id), "canonical_domain": canonical}) + resolved = tls_lookup.run([str(role_id), "url.base"], variables=variables) + canonical_url = str(resolved[0]).rstrip("/") + results.append( + { + "id": str(role_id), + "canonical_domain": canonical, + "canonical_url": canonical_url, + } + ) results.sort(key=lambda r: r["id"]) return [results] diff --git a/roles/web-app-matomo/files/playwright/playwright.spec.js b/roles/web-app-matomo/files/playwright/playwright.spec.js index a3389e4ce7..5c1339162c 100644 --- a/roles/web-app-matomo/files/playwright/playwright.spec.js +++ b/roles/web-app-matomo/files/playwright/playwright.spec.js @@ -347,6 +347,41 @@ test("matomo SitesManager registers a tracker site for every consumer role", asy void cookies; }); +const matomoCanonicalDomain = (() => { + try { + return new URL(appBaseUrl).hostname; + } catch { + return ""; + } +})(); + +for (const target of matomoTargetRoles) { + test(`matomo tracker injected in ${target.id} (${target.canonical_domain})`, async ({ page }) => { + expect( + target.canonical_url, + `Expected canonical_url in MATOMO_TARGET_ROLES_JSON entry for ${target.id}` + ).toBeTruthy(); + const targetUrl = `${target.canonical_url}/`; + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const html = await page.content(); + + expect( + html, + `Expected matomo tracker '_paq' marker in ${target.id} HTML body` + ).toContain("_paq"); + expect( + html, + `Expected matomo tracker URL ('matomo.php') in ${target.id} HTML body` + ).toContain("matomo.php"); + if (matomoCanonicalDomain) { + expect( + html, + `Expected matomo host '${matomoCanonicalDomain}' referenced in ${target.id} HTML body` + ).toContain(matomoCanonicalDomain); + } + }); +} + // Persona scenarios. // Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js // so every role's persona flow stays consistent. diff --git a/roles/web-svc-logout/files/playwright/playwright.spec.js b/roles/web-svc-logout/files/playwright/playwright.spec.js new file mode 100644 index 0000000000..a03ae0b3b8 --- /dev/null +++ b/roles/web-svc-logout/files/playwright/playwright.spec.js @@ -0,0 +1,55 @@ +const { test, expect } = require("@playwright/test"); +const { decodeDotenvQuotedValue, runAdminFlow, runBiberFlow, runGuestFlow } = require("./personas"); + +test.use({ ignoreHTTPSErrors: true }); + +const appBaseUrl = decodeDotenvQuotedValue(process.env.APP_BASE_URL); +const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN); + +test.beforeEach(() => { + expect(appBaseUrl, "APP_BASE_URL must be set in the Playwright env file").toBeTruthy(); + expect(canonicalDomain, "CANONICAL_DOMAIN must be set in the Playwright env file").toBeTruthy(); +}); + +const logoutTargetRoles = (() => { + const raw = process.env.LOGOUT_TARGET_ROLES_JSON || "[]"; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +})(); + +for (const target of logoutTargetRoles) { + test(`universal-logout injected in ${target.id} (${target.canonical_domain})`, async ({ page }) => { + expect( + target.canonical_url, + `Expected canonical_url in LOGOUT_TARGET_ROLES_JSON entry for ${target.id}` + ).toBeTruthy(); + const targetUrl = `${target.canonical_url}/`; + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const html = await page.content(); + + expect( + html, + `Expected universal-logout 'logout.js' script reference in ${target.id} HTML body` + ).toContain("logout.js"); + }); +} + +// Persona scenarios. +// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js +// so every role's persona flow stays consistent. + +test("guest: public-landing → auth chain → never authenticated", async ({ page }) => { + await runGuestFlow(page); +}); + +test("biber: app → universal logout", async ({ page }) => { + await runBiberFlow(page); +}); + +test("administrator: app → universal logout", async ({ page }) => { + await runAdminFlow(page); +}); diff --git a/roles/web-svc-logout/meta/services.yml b/roles/web-svc-logout/meta/services.yml index a90a67eade..45813d21cc 100644 --- a/roles/web-svc-logout/meta/services.yml +++ b/roles/web-svc-logout/meta/services.yml @@ -1,4 +1,5 @@ --- +# nocheck: playwright-service-flag — self-provider; injection presence is asserted per consumer via roles_with_service('logout') logout: enabled: true shared: true @@ -14,14 +15,17 @@ logout: local: http: 8048 lifecycle: beta +# nocheck: playwright-service-flag — web-svc-logout has no user-facing surface; matomo/css/javascript injection is asserted by the respective provider's spec matomo: enabled: "{{ 'web-app-matomo' in group_names }}" shared: "{{ 'web-app-matomo' in group_names }}" # nocheck: dynamic-flag +# nocheck: playwright-service-flag — web-svc-logout has no user-facing surface; CSS injection is asserted on the consuming role surfaces css: enabled: true shared: true # nocheck: dynamic-flag +# nocheck: playwright-service-flag — web-svc-logout has no user-facing surface; JS injection is asserted on the consuming role surfaces javascript: enabled: false shared: true diff --git a/roles/web-svc-logout/templates/playwright.env.j2 b/roles/web-svc-logout/templates/playwright.env.j2 new file mode 100644 index 0000000000..d480ed94d6 --- /dev/null +++ b/roles/web-svc-logout/templates/playwright.env.j2 @@ -0,0 +1,18 @@ +APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }} +CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }} +ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }} +ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }} +BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }} +BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }} + +# Web-svc-logout has no user-facing surface; the role only ships the +# universal-logout JS that is injected into every consumer role. The +# persona stubs exist to satisfy the persona-naming lint; explicit-block +# both because they have no role surface to land on. +PERSONA_BIBER_BLOCKED=true +PERSONA_ADMINISTRATOR_BLOCKED=true + +# Injection-target list: every role declaring +# `services.logout.{enabled,shared}`. The logout spec parameterises +# one injection-presence assertion per entry. +LOGOUT_TARGET_ROLES_JSON={{ lookup('roles_with_service', 'logout') | to_json | dotenv_quote }} diff --git a/tests/unit/plugins/lookup/test_roles_with_service.py b/tests/unit/plugins/lookup/test_roles_with_service.py index 96baa63ff1..68bf2821b4 100644 --- a/tests/unit/plugins/lookup/test_roles_with_service.py +++ b/tests/unit/plugins/lookup/test_roles_with_service.py @@ -42,11 +42,22 @@ class RolesWithServiceLookupTests(unittest.TestCase): return lm def _run(self, terms, applications: dict, vars_: dict | None = None): - """Run the lookup with `get_merged_applications` patched to return - the given applications dict so the tests stay hermetic.""" + """Run the lookup with `get_merged_applications` and the `tls` + sub-lookup patched to stay hermetic. The stubbed tls resolves + ``[role_id, "url.base"]`` to a deterministic URL so the lookup + can finish building each result entry without the real domain + machinery.""" lookup = self._make_lookup(vars_ or {}) - with patch.object( - self.mod, "get_merged_applications", return_value=applications + + class _StubTls: + def run(self, terms_, variables=None, **kwargs): + return [f"https://{terms_[0]}.example.com"] + + with ( + patch.object( + self.mod, "get_merged_applications", return_value=applications + ), + patch.object(self.mod.lookup_loader, "get", return_value=_StubTls()), ): return lookup.run(terms, variables=vars_ or {}) @@ -74,7 +85,15 @@ class RolesWithServiceLookupTests(unittest.TestCase): result = self._run(["dashboard"], applications) self.assertEqual( result, - [[{"id": "web-app-foo", "canonical_domain": "foo.example.com"}]], + [ + [ + { + "id": "web-app-foo", + "canonical_domain": "foo.example.com", + "canonical_url": "https://web-app-foo.example.com", + } + ] + ], ) def test_falsy_enabled_excludes_role(self): @@ -131,7 +150,15 @@ class RolesWithServiceLookupTests(unittest.TestCase): result = self._run(["dashboard"], applications) self.assertEqual( result, - [[{"id": "web-app-foo", "canonical_domain": "foo.example.com"}]], + [ + [ + { + "id": "web-app-foo", + "canonical_domain": "foo.example.com", + "canonical_url": "https://web-app-foo.example.com", + } + ] + ], ) def test_results_are_sorted_by_role_id(self):