Files
computer-playbook/tests/unit/python/plugins/lookup/test_sso_oidc_plugin.py
T
kevinveenbirkenbachandClaude Opus 5.5 ec0fde6561 test(lookup): restore sys.modules after loading the sso plugins fresh
test_sso.py, test_sso_oidc_plugin.py and test_sso_proxy_consumers.py evict utils.cache.applications, utils.cache and the roles packages from sys.modules to import their plugin against fresh modules, and never put the originals back. A later test module in the same worker then held two copies of utils.cache.applications: plugins.lookup.applications, imported earlier, kept calling the old one, while test_email patched and reset the new one. Its carrier tests counted 0 renders and failed (CI run 40869769501, test_email.py:556 and :570, and the IAM merge gate), depending only on which modules xdist placed in one worker.

_load_module now runs inside mock.patch.dict(sys.modules): the plugin keeps the fresh modules it was built against, and sys.modules returns to its prior state, so the next module sees exactly what it would have without these tests. `pytest test_sso.py test_email.py` failed the two carrier tests before and passes 67/67 with the three SSO modules first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:52:02 +01:00

186 lines
5.8 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.
"""
with mock.patch.dict(sys.modules):
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()