feat(playwright): expose canonical_url in roles_with_service; add web-svc-logout injection spec; parameterize matomo tracker check

- plugins/lookup/roles_with_service.py: resolve url.base via the tls
  sublookup and return canonical_url alongside id/canonical_domain.
- web-app-matomo/files/playwright/playwright.spec.js: parameterised
  matomo-tracker injection assertion per consumer over
  MATOMO_TARGET_ROLES_JSON, using target.canonical_url.
- web-svc-logout: new files/playwright/playwright.spec.js + templates/
  playwright.env.j2; checks universal-logout injection on every consumer
  declared in LOGOUT_TARGET_ROLES_JSON. meta/services.yml gains nocheck
  markers explaining why this provider has no user-facing surface to
  assert on.
- tests/unit/plugins/lookup/test_roles_with_service.py: cover the new
  canonical_url field via a stubbed tls sublookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-16 23:24:56 +02:00
1 parent 1f665b8bcc
commit 6fa7bd2f57
6 files changed
+163 -26

No files matched your search

+18 -20
View File
@@ -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.<service>.{
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.<service>.{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]
@@ -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.
@@ -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);
});
+4
View File
@@ -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
@@ -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 }}
@@ -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):