Files
computer-playbook/tests/unit/python/plugins/lookup/test_addon_env_flags.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

258 lines
9.5 KiB
Python

import unittest
import unittest.mock as mock
from unittest.mock import patch
from ansible.errors import AnsibleError
from plugins.lookup.addon_env_flags import LookupModule, env_key
def _applications_loader(apps):
def _get(name, *a, **k):
if name == "applications":
return mock.MagicMock(run=lambda *_a, **_k: [apps])
return mock.MagicMock(run=lambda *_a, **_k: [None])
loader_mock = mock.MagicMock()
loader_mock.get.side_effect = _get
return loader_mock
class TestEnvKey(unittest.TestCase):
"""env_key() must match addon-gating.js envKey() exactly."""
def test_simple(self):
self.assertEqual(env_key("collectives"), "COLLECTIVES_ADDON_ENABLED")
def test_underscores_preserved(self):
self.assertEqual(env_key("files_bpm"), "FILES_BPM_ADDON_ENABLED")
self.assertEqual(
env_key("integration_gitlab"), "INTEGRATION_GITLAB_ADDON_ENABLED"
)
def test_non_alnum_runs_collapse(self):
self.assertEqual(
env_key("ldap-authenticator"), "LDAP_AUTHENTICATOR_ADDON_ENABLED"
)
self.assertEqual(env_key("a.b-c"), "A_B_C_ADDON_ENABLED")
class TestAddonEnvFlagsLookup(unittest.TestCase):
def setUp(self):
self.lookup = LookupModule()
self.lookup._loader = mock.MagicMock()
self.addons = {
"collectives": {"enabled": True, "required": True},
"contacts": {"enabled": True, "required": True},
"optional_on": {"enabled": True, "required": False},
"required_off": {"enabled": False, "required": True},
"off_optional": {"enabled": False, "required": False},
"str_flags": {"enabled": "true", "required": "true"},
"no_required": {"enabled": True},
}
self._patchers = [
patch(
"plugins.lookup.addon_env_flags.lookup_loader",
_applications_loader({}),
),
patch("plugins.lookup.addon_env_flags.get", return_value=self.addons),
patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
),
]
for p in self._patchers:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._patchers])
def _flags(self):
out = self.lookup.run(["web-app-x"], variables={})[0]
return dict(line.split("=", 1) for line in out.splitlines())
def test_enabled_and_required_true(self):
f = self._flags()
self.assertEqual(f["COLLECTIVES_ADDON_ENABLED"], "true")
self.assertEqual(f["CONTACTS_ADDON_ENABLED"], "true")
def test_required_false_skipped_even_if_enabled(self):
self.assertEqual(self._flags()["OPTIONAL_ON_ADDON_ENABLED"], "false")
def test_disabled_is_false(self):
f = self._flags()
self.assertEqual(f["REQUIRED_OFF_ADDON_ENABLED"], "false")
self.assertEqual(f["OFF_OPTIONAL_ADDON_ENABLED"], "false")
def test_missing_required_defaults_false(self):
self.assertEqual(self._flags()["NO_REQUIRED_ADDON_ENABLED"], "false")
def test_string_flags_coerced(self):
self.assertEqual(self._flags()["STR_FLAGS_ADDON_ENABLED"], "true")
def test_one_line_per_addon_sorted_by_key(self):
lines = self.lookup.run(["web-app-x"], variables={})[0].splitlines()
self.assertEqual(len(lines), len(self.addons))
keys = [ln.split("=", 1)[0] for ln in lines]
self.assertEqual(keys, sorted(keys))
def test_no_terms_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run([], variables={})
def test_too_many_terms_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run(["a", "b"], variables={})
class TestNoAddons(unittest.TestCase):
def test_empty_addons_yields_empty_string(self):
lookup = LookupModule()
lookup._loader = mock.MagicMock()
with (
patch(
"plugins.lookup.addon_env_flags.lookup_loader",
_applications_loader({}),
),
patch("plugins.lookup.addon_env_flags.get", return_value={}),
patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
),
):
self.assertEqual(lookup.run(["web-app-x"], variables={}), [""])
class TestBridgeDeploymentGating(unittest.TestCase):
"""A bridged addon's flag is true only when one of its partner roles is in the
deployed playwright set (TEST_E2E_PLAYWRIGHT_APPS). When the set cannot be
resolved the gating is skipped (flag falls back to enabled AND required)."""
def setUp(self):
self.lookup = LookupModule()
self.lookup._loader = mock.MagicMock()
self.addons = {
"gl_bridge": {"enabled": True, "required": True, "bridges": ["gitlab"]},
"masto_bridge": {
"enabled": True,
"required": True,
"bridges": ["mastodon"],
},
"multi_bridge": {
"enabled": True,
"required": True,
"bridges": ["openwebui", "flowise"],
},
"no_bridge": {"enabled": True, "required": True},
"disabled_bridge": {
"enabled": False,
"required": True,
"bridges": ["gitlab"],
},
}
self._patchers = [
patch(
"plugins.lookup.addon_env_flags.lookup_loader",
_applications_loader({}),
),
patch("plugins.lookup.addon_env_flags.get", return_value=self.addons),
patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
),
]
for p in self._patchers:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._patchers])
def _flags(self, variables):
out = self.lookup.run(["web-app-x"], variables=variables)[0]
return dict(line.split("=", 1) for line in out.splitlines())
def test_partner_deployed_keeps_true(self):
f = self._flags(
{"TEST_E2E_PLAYWRIGHT_APPS": ["web-app-gitlab", "web-app-nextcloud"]}
)
self.assertEqual(f["GL_BRIDGE_ADDON_ENABLED"], "true")
def test_partner_not_deployed_gated_false(self):
f = self._flags(
{"TEST_E2E_PLAYWRIGHT_APPS": ["web-app-gitlab", "web-app-nextcloud"]}
)
self.assertEqual(f["MASTO_BRIDGE_ADDON_ENABLED"], "false")
def test_any_partner_of_multi_bridge_counts(self):
f = self._flags({"TEST_E2E_PLAYWRIGHT_APPS": ["svc-ai-flowise"]})
self.assertEqual(f["MULTI_BRIDGE_ADDON_ENABLED"], "true")
def test_no_bridge_addon_unaffected(self):
f = self._flags({"TEST_E2E_PLAYWRIGHT_APPS": ["web-app-nextcloud"]})
self.assertEqual(f["NO_BRIDGE_ADDON_ENABLED"], "true")
def test_disabled_addon_stays_false(self):
f = self._flags({"TEST_E2E_PLAYWRIGHT_APPS": ["web-app-gitlab"]})
self.assertEqual(f["DISABLED_BRIDGE_ADDON_ENABLED"], "false")
def test_no_deployed_set_falls_back_no_gating(self):
f = self._flags({})
self.assertEqual(f["MASTO_BRIDGE_ADDON_ENABLED"], "true")
self.assertEqual(f["GL_BRIDGE_ADDON_ENABLED"], "true")
def test_deployed_set_as_string_is_split(self):
f = self._flags(
{"TEST_E2E_PLAYWRIGHT_APPS": "web-app-gitlab web-app-nextcloud"}
)
self.assertEqual(f["GL_BRIDGE_ADDON_ENABLED"], "true")
self.assertEqual(f["MASTO_BRIDGE_ADDON_ENABLED"], "false")
class TestBridgeResolvesProviderKey(unittest.TestCase):
"""A bridge may name the service key a role registers, not only its entity.
'sso' is provided by web-app-keycloak, whose id ends in neither '-sso' nor
'sso', so the entity-suffix rule alone can never match it."""
def setUp(self):
self.lookup = LookupModule()
self.lookup._loader = mock.MagicMock()
self.applications = {
"web-app-keycloak": {
"services": {
"keycloak": {"enabled": True, "shared": True, "provides": "sso"}
}
},
"web-app-wordpress": {"services": {}},
}
self.addons = {
"ca_trust": {"enabled": True, "required": True, "bridges": ["sso"]},
}
self._patchers = [
patch(
"plugins.lookup.addon_env_flags.lookup_loader",
_applications_loader(self.applications),
),
patch("plugins.lookup.addon_env_flags.get", return_value=self.addons),
patch(
"plugins.lookup.addon_env_flags._render_with_templar",
side_effect=lambda v, **k: v,
),
]
for p in self._patchers:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._patchers])
def _flag(self, apps):
out = self.lookup.run(
["web-app-wordpress"], variables={"TEST_E2E_PLAYWRIGHT_APPS": apps}
)[0]
return dict(line.split("=", 1) for line in out.splitlines())[
"CA_TRUST_ADDON_ENABLED"
]
def test_provider_role_deployed_keeps_true(self):
self.assertEqual(self._flag(["web-app-keycloak", "web-app-wordpress"]), "true")
def test_provider_role_absent_gates_false(self):
self.assertEqual(self._flag(["web-app-wordpress"]), "false")
if __name__ == "__main__":
unittest.main()