Files
computer-playbook/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py
T
kevinveenbirkenbachandClaude Opus 5 9a68f0eaaf fix(sso): read the OIDC gate's flags through the rendering config lookup
meta/addons/oidc_login.yml gates on lookup('sso_oidc_plugin'), which the applications render evaluates while it is still in flight. Under that re-entry guard the merged view hands out service flags as their own Jinja text, and bool("{{ ... }}") is True. The lookup read services.sso.enabled and services.ldap.enabled as enabled on a deployment with neither Keycloak nor OpenLDAP and announced oidc_login, so Nextcloud advertised a login hand-off to a provider that is not there.

lookup('config', ...) renders the value it returns, which is why the roughly ninety '| bool' sites in meta/ do not have this problem. 238bc285c7 established reaching the merged config through the lookup loader; this one had not been converted. utils.roles.applications.config.get is the raw accessor and does not render.

Measured against the real render: with only web-app-nextcloud deployed the addon set is now empty; adding Keycloak yields sociallogin; adding OpenLDAP as well yields oidc_login. That is the resolution order the lookup's own docstring documents. All 14 nextcloud variants pin both flags as literal booleans, so no CI-deployed variant changes.

The stub next to the plugin answered every lookup with the applications payload; it now mirrors lookup('config', app, path, default). A stub resolves a path but does not render it, so it cannot see this defect at all: the regression test renders for real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:52:15 +02:00

185 lines
5.7 KiB
Python

import importlib
import importlib.util
import sys
import unittest
import unittest.mock as mock
from ansible.errors import AnsibleError
from . import PROJECT_ROOT
def _load_module(rel_path: str, name: str):
"""Import ``rel_path`` fresh, evicting sibling-test stubs of
``utils.roles.applications.config`` from ``sys.modules`` first.
"""
for key in (
"utils.roles.applications.config",
"utils.roles.applications",
"utils",
"utils.cache.applications",
"utils.cache",
):
sys.modules.pop(key, None)
importlib.import_module("utils.roles.applications.config")
importlib.import_module("utils.cache.applications")
path = PROJECT_ROOT / rel_path
spec = importlib.util.spec_from_file_location(name, str(path))
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(mod)
return mod
def _apps(*, oidc_enabled=True, ldap_enabled=None, flavor=None, include_app=True):
"""Build a minimal merged `applications` dict for the Nextcloud role.
`oidc_enabled` defaults to True so existing flavor-selection tests
stay focused on the ldap/flavor axis. The OIDC-off fast-path has
its own dedicated tests.
"""
if not include_app:
return {}
sso_block: dict = {"enabled": oidc_enabled}
if flavor is not None:
sso_block["oidc"] = {"plugin": flavor}
services_block: dict = {"sso": sso_block}
if ldap_enabled is not None:
services_block["ldap"] = {"enabled": ldap_enabled}
return {
"web-app-nextcloud": {
"services": services_block,
},
}
class OidcFlavorLookupTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module(
"plugins/lookup/sso_oidc_plugin.py",
"sso_oidc_plugin",
)
def setUp(self):
self._patcher = mock.patch.object(self.mod, "lookup_loader")
self._loader_mock = self._patcher.start()
self._stub_payload = None
self._loader_mock.get.side_effect = self._stub_lookup
def _stub_lookup(self, name, *_args, **_kwargs):
"""Stand in for the lookups the plugin consumes.
``config`` resolves a dotted path against the payload the way
``lookup('config', app, path, default)`` does with a default supplied;
anything else answers with the payload itself.
"""
if name == "config":
return mock.MagicMock(
run=lambda terms, **_kw: [self._stub_config(*terms)],
)
return mock.MagicMock(run=lambda *_a, **_kw: [self._stub_payload])
def _stub_config(self, application_id, config_path, default=None):
node = (self._stub_payload or {}).get(application_id)
for part in str(config_path).split("."):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return node
def tearDown(self):
self._patcher.stop()
def _run(self, applications, *, terms=None):
self._stub_payload = applications
lk = self.mod.LookupModule()
lk._loader = mock.MagicMock()
return lk.run(terms or [], variables={"applications": applications})
def test_ldap_enabled_returns_oidc_login(self):
self.assertEqual(
self._run(_apps(ldap_enabled=True)),
["oidc_login"],
)
def test_ldap_disabled_returns_sociallogin(self):
self.assertEqual(
self._run(_apps(ldap_enabled=False)),
["sociallogin"],
)
def test_ldap_missing_defaults_to_sociallogin(self):
self.assertEqual(
self._run(_apps()),
["sociallogin"],
)
def test_missing_application_returns_empty(self):
self.assertEqual(
self._run({"some-other-app": {}}),
[""],
)
def test_oidc_disabled_returns_empty(self):
self.assertEqual(
self._run(_apps(oidc_enabled=False)),
[""],
)
self.assertEqual(
self._run(_apps(oidc_enabled=False, ldap_enabled=True)),
[""],
)
self.assertEqual(
self._run(_apps(oidc_enabled=False, flavor="oidc_login")),
[""],
)
def test_explicit_flavor_overrides_ldap_fallback(self):
self.assertEqual(
self._run(_apps(ldap_enabled=True, flavor="sociallogin")),
["sociallogin"],
)
self.assertEqual(
self._run(_apps(ldap_enabled=False, flavor="user_oidc")),
["user_oidc"],
)
def test_explicit_flavor_is_stripped(self):
self.assertEqual(
self._run(_apps(ldap_enabled=False, flavor=" oidc_login ")),
["oidc_login"],
)
def test_blank_explicit_flavor_falls_back_to_ternary(self):
self.assertEqual(
self._run(_apps(ldap_enabled=True, flavor=" ")),
["oidc_login"],
)
self.assertEqual(
self._run(_apps(ldap_enabled=False, flavor="")),
["sociallogin"],
)
def test_null_explicit_flavor_falls_back_to_ternary(self):
self.assertEqual(
self._run(_apps(ldap_enabled=True, flavor=None)),
["oidc_login"],
)
def test_non_string_explicit_flavor_is_ignored(self):
self.assertEqual(
self._run(_apps(ldap_enabled=True, flavor=42)),
["oidc_login"],
)
def test_rejects_positional_terms(self):
lk = self.mod.LookupModule()
with self.assertRaises(AnsibleError):
lk.run(["unexpected"], variables={"applications": _apps()})
if __name__ == "__main__":
unittest.main()