mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
1 parent
1f665b8bcc
commit
6fa7bd2f57
6 files changed
+163
-26
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user