Files
computer-playbook/tests/unit/utils/roles/validation/test_invokable.py
T
kevinveenbirkenbachandClaude Opus 4.7 24d4ab8e4a refactor(sso)\!: collapse oauth2 + oidc into unified services.sso block (req 21)
Implements docs/requirements/021-sso-flavor-migration.md atomically:

* Schema: every role's meta/services.yml replaces the parallel
  `oauth2:` / `oidc:` top-level blocks with a single `sso:` block
  carrying `enabled`, `shared`, and a `flavor` discriminator
  (`oidc` | `oauth2` | `saml`; default `oidc`). Flavor-specific
  sub-keys live under `sso.<flavor>.*` (`sso.oauth2.{origin,acl,
  allowed_groups}`, `sso.oidc.plugin`).
* Provider role: roles/web-app-oauth2-proxy/ is deleted in its
  entirety; its 5 sidecar templates move under
  roles/web-app-keycloak/templates/sso_proxy/, and the per-consumer
  rendering task lives at roles/web-app-keycloak/tasks/sso_proxy.yml.
  web-app-keycloak now declares `provides: sso`.
* Runtime: new resolver utils/roles/applications/services/sso.py
  centralises the SSO predicates (`is_enabled`, `is_proxy_gated`,
  `is_oidc_native`) and the oauth2 sub-values
  (`oauth2_origin_{host,port}`, `oauth2_acl`, `oauth2_allowed_groups`).
  Three lookup plugins consume the resolver: `sso` (single-app
  predicate access), `sso_proxy_consumers` (enumerates oauth2-flavored
  consumers), `sso_oidc_plugin` (renamed from `oidc_flavor`, Nextcloud
  plugin selector). sys-stk-backend, sys-svc-compose, sys-svc-proxy
  and plugins/filter/compose_volumes.py all gate on
  `lookup('sso', ..., 'is_proxy_gated')` instead of manually combining
  `enabled` and `flavor`.
* Identifier renames repo-wide: `services.<entity>.ports.local.oauth2`
  → `ports.local.sso`; `PORT_BANDS.local.oauth2` → `PORT_BANDS.local.sso`;
  `OAUTH2_PROXY_*` env/Jinja vars → `SSO_PROXY_*`;
  `oauth2_proxy_cookie_secret` → `sso_proxy_cookie_secret`;
  `OAUTH2_SERVICE_ENABLED` + `OIDC_SERVICE_ENABLED` collapse into
  `SSO_SERVICE_ENABLED`; Playwright gating helpers
  (isServiceEnabled/skipUnlessServiceEnabled/requireService/safeIsEnabled/
  safeSkipUnlessEnabled) move from `oidc`/`oauth2` to `sso`.
* Dual-block resolution: per req's Background-Table, web-app-bookwyrm
  is `flavor: oauth2`; web-app-gitea and web-app-friendica keep
  `flavor: oauth2` with their existing `oauth2.acl.blacklist`
  (/user/login) and `oauth2.acl.whitelist` (federation + discovery
  endpoints) — preserving the operative oauth2-proxy gating.
* Tests: the two mutual-exclusion guards
  (tests/integration/iam/oauth2_oidc/test_mutual_exclusive.py and
  test_acl_mutual_exclusive.py) are deleted per Decision #10;
  test_oauth2_contract.py is renamed and rewritten to
  test_sso_contract.py, asserting that `flavor: oauth2` roles carry
  `oauth2.origin.{host,port}` plus `ports.local.sso`; test_sso.py
  (web-app integration) now requires the unified `sso:` block with an
  explicit flavor or a `# nocheck: sso` opt-out; new
  tests/lint/repository/no_legacy_sso_paths/ guards the source tree
  against re-introducing the legacy strings.
* Docs: req 21 itself has every AC checked off, and reqs 006, 008,
  009, 013, 015, 016, 019, 020 plus 10+ role READMEs and
  docs/contributing/actions/testing/suppression.md are updated to the
  unified SSO naming.

Empirically validated end-to-end against the validation app set
(web-app-keycloak, web-app-nextcloud, web-app-bookwyrm, web-app-gitea,
web-app-friendica, web-app-dashboard) in matrix variants 0 and 1; v2
also passes the 4 target roles' Playwright after a documented
pre-existing KC_HOSTNAME-drift workaround (separate concern, not
caused by this refactor).

BREAKING CHANGE: every consumer of `services.oauth2.*`,
`services.oidc.*`, `OAUTH2_PROXY_*`, `OAUTH2_SERVICE_ENABLED`,
`OIDC_SERVICE_ENABLED`, `oauth2_proxy_cookie_secret`,
`oauth2_proxy_application_id`, `ports.local.oauth2`, the
`web-app-oauth2-proxy` role, or the legacy gating helper service names
(`oidc`/`oauth2`) must migrate to the unified `sso` shape documented
in docs/requirements/021-sso-flavor-migration.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:59:59 +02:00

140 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/unit/utils/test_invokable.py
from __future__ import annotations
import re
import shutil
import tempfile
from pathlib import Path
from unittest import TestCase, main
from unittest.mock import patch
import utils.roles.validation.invokable as inv
from utils.cache.yaml import dump_yaml
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
class TestInvokable(TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp(prefix="invokable_modutils_test_"))
self.roles_dir = self.tmp / "roles"
self.roles_dir.mkdir(parents=True, exist_ok=True)
self.categories_file = self.roles_dir / "categories.yml"
# This structure triggers the YAML fallback in _get_invokable_paths()
# because it expects "categories", but we will patch _get_invokable_paths() anyway
dump_yaml(
self.categories_file,
{"categories": [{"invokable_paths": ["web-app", "update", "util-desk"]}]},
)
def mk_role(name: str, application_id: str | None = None) -> None:
rd = self.roles_dir / name
(rd / "vars").mkdir(parents=True, exist_ok=True)
if application_id is not None:
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {"application_id": application_id})
else:
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {})
mk_role("web-app-nextcloud", "web-app-nextcloud")
mk_role("web-app-matomo", "matomo-app")
mk_role("web-svc-nginx", None) # not invokable by our patched paths
mk_role("update", None) # exact match
mk_role("util-desk-custom", None) # prefix match
def tearDown(self) -> None:
shutil.rmtree(self.tmp)
def test_list_invokable_app_ids(self) -> None:
with (
patch.object(inv, "PROJECT_ROOT", self.tmp),
patch.object(
inv,
"_get_invokable_paths",
return_value=["web-app", "update", "util-desk"],
),
):
got = inv.list_invokable_app_ids()
self.assertEqual(
got,
sorted(["web-app-nextcloud", "matomo-app", "update", "util-desk-custom"]),
)
def test_list_invokables_by_type(self) -> None:
with (
patch.object(inv, "PROJECT_ROOT", self.tmp),
patch.object(
inv,
"_get_invokable_paths",
return_value=["web-app", "update", "util-desk"],
),
):
grouped = inv.list_invokables_by_type()
# server: web-app-* minus excluded oauth2 proxy (not present in test)
self.assertIn("server", grouped)
self.assertIn("workstation", grouped)
self.assertIn("universal", grouped)
self.assertEqual(grouped["server"], sorted(["web-app-nextcloud", "matomo-app"]))
self.assertEqual(grouped["workstation"], sorted(["util-desk-custom"]))
# universal = invokable - (server ∪ workstation) -> update remains
self.assertEqual(grouped["universal"], ["update"])
def test_list_invokables_by_type_exclude(self) -> None:
# Exercise the exclude_re path of DeploymentTypeRule via a
# synthetic rule. The historical DEFAULT_RULES exclude (the now
# dissolved oauth2-proxy helper role) was removed when its
# sidecar logic was absorbed into web-app-keycloak.
rd = self.roles_dir / "web-app-excluded"
(rd / "vars").mkdir(parents=True, exist_ok=True)
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {})
rules = (
inv.DeploymentTypeRule(
name="server",
include_re=re.compile(r"^(web-app-|web-svc-)"),
exclude_re=re.compile(r"^web-app-excluded$"),
),
inv.DeploymentTypeRule(
name="universal", include_re=re.compile(r".*"), exclude_re=None
),
)
with (
patch.object(inv, "PROJECT_ROOT", self.tmp),
patch.object(
inv,
"_get_invokable_paths",
return_value=["web-app", "update", "util-desk"],
),
):
grouped = inv.list_invokables_by_type(rules=rules)
self.assertNotIn("web-app-excluded", grouped["server"])
def test_types_from_group_names(self) -> None:
with (
patch.object(inv, "PROJECT_ROOT", self.tmp),
patch.object(
inv,
"_get_invokable_paths",
return_value=["web-app", "update", "util-desk"],
),
):
got = inv.types_from_group_names(
[
"all", # not invokable -> ignored
"web-app-nextcloud", # invokable + matches server rule
"util-desk-custom", # invokable + matches workstation rule
"update", # invokable leftover -> universal
"foo", # not invokable -> ignored
]
)
self.assertEqual(got, ["server", "universal", "workstation"])
if __name__ == "__main__":
main()