Files
computer-playbook/tests/unit/python/plugins/lookup/test_cert.py
T
kevinveenbirkenbachandClaude Opus 5 0af8ee5992 Merge branch 'feature/updater' into feature/store-i18n
Five conflicts, resolved by what each side is for rather than by taking a
side:

- sys-service/01_prune.yml and sys-service-terminator/02_unlock.yml take
  the incoming single `systemctl` invocation over four module loops. The
  unlock side also drops an unmask step: the list it runs on is built with
  `reject('search', ' masked')`, so it never held a masked unit and the
  step did nothing.
- sys-stk-front-proxy/tasks/main.yml keeps this branch's version. The block
  the other side still carries there moved into vhost.yml, which runs it
  once per network sibling; taking theirs would have duplicated it.
  vhost.yml holds every task of that block, the global injection and the
  TLS layer included.
- web-app-matomo/08_reinject_consumers.yml likewise keeps the per-vhost
  loop, which re-renders the injection for every network sibling and passes
  matomo_token_override.
- utils/cache/domains.py combines both: the incoming side rendered
  DOMAIN_PRIMARY with the real templar and variables, which is right, but
  added a second renderer beside the module's own. _render_plain now takes
  that context optionally, so the other two call sites stay as they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:57:56 +02:00

396 lines
16 KiB
Python

import sys
import unittest
import unittest.mock as mock
from unittest.mock import patch
from ansible.errors import AnsibleError
import utils.tls_common as _tls_common
from plugins.lookup.cert import LookupModule
from utils.cache import _reset_cache_for_tests
sys.modules.setdefault("ansible.module_utils.tls_common", _tls_common)
class TestCertPlanLookup(unittest.TestCase):
def setUp(self):
_reset_cache_for_tests()
self.lookup = LookupModule()
self.lookup._loader = mock.MagicMock()
self.domains = {
"web-app-a": "a.example",
"web-app-b": ["b.example", "b-alt.example"],
"web-app-c": {"primary": "c.example", "api": "api.c.example"},
}
self.applications = {
"web-app-a": {"domains": {"aliases": ["alias-a.example"]}},
"web-app-b": {"server": {"tls": {"flavor": "self_signed"}}},
"web-app-c": {
"server": {
"tls": {"flavor": "self_signed", "domains_san": ["x.example"]}
}
},
"web-app-off": {"server": {"tls": {"enabled": False}}},
}
self.vars = {
"domains": self.domains,
"applications": self.applications,
"TLS_ENABLED": True,
"TLS_MODE": "letsencrypt",
"CERTBOT_FLAVOR": "san",
"LETSENCRYPT_LIVE_PATH": "/etc/letsencrypt/live/",
"TLS_SELFSIGNED_BASE_PATH": "/etc/infinito.nexus/selfsigned",
"TLS_SELFSIGNED_SCOPE": "global",
"CURRENT_PLAY_DOMAINS_ALL": [
"a.example",
"b.example",
"b-alt.example",
"c.example",
"api.c.example",
],
}
def _domains_from_vars(_terms, variables=None, **_kwargs):
return [(variables or {}).get("domains", {})]
def _applications_from_vars(_terms, variables=None, **_kwargs):
return [(variables or {}).get("applications", {})]
def _get(name, *_a, **_k):
if name == "domains":
return mock.MagicMock(run=_domains_from_vars)
if name == "applications":
return mock.MagicMock(run=_applications_from_vars)
return mock.MagicMock(run=lambda *_ra, **_rk: [{}])
loader_patcher = patch("plugins.lookup.cert.lookup_loader")
loader_mock = loader_patcher.start()
loader_mock.get.side_effect = _get
self.addCleanup(loader_patcher.stop)
def tearDown(self):
_reset_cache_for_tests()
def test_letsencrypt_plan_paths_and_sans_default(self):
out = self.lookup.run(["web-app-a"], variables=self.vars, mode="app")[0]
self.assertEqual(out["mode"], "letsencrypt")
self.assertEqual(
out["files"]["cert"], "/etc/letsencrypt/live/a.example/fullchain.pem"
)
self.assertEqual(
out["files"]["key"], "/etc/letsencrypt/live/a.example/privkey.pem"
)
self.assertEqual(out["domains"]["san"], ["a.example"])
def test_sans_leave_out_networks_without_tls(self):
v = dict(self.vars)
v["domains"] = dict(self.domains)
v["domains"]["web-app-a"] = ["a.example", "a.abc.onion"]
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
self.assertEqual(out["domains"]["san"], ["a.example"])
def test_letsencrypt_plan_uses_cert_name_override(self):
v = dict(self.vars)
v["applications"] = dict(self.applications)
v["applications"]["web-app-a"] = {
"server": {"tls": {"letsencrypt_cert_name": "mycert"}}
}
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
self.assertEqual(out["cert_id"], "mycert")
self.assertEqual(
out["files"]["cert"], "/etc/letsencrypt/live/mycert/fullchain.pem"
)
def test_letsencrypt_alias_vhost_gets_its_own_lineage_link(self):
for flavor in ("san", "dedicated"):
v = dict(self.vars, CERTBOT_FLAVOR=flavor)
out = self.lookup.run(["alias-a.example"], variables=v)[0]
self.assertEqual(out["application_id"], "web-app-a")
self.assertEqual(out["domain"], "a.example")
self.assertEqual(
out["files"]["cert"],
"/etc/letsencrypt/live/alias-a.example/fullchain.pem",
)
def test_letsencrypt_wildcard_redirect_source_gets_its_own_lineage_link(self):
v = dict(
self.vars,
CURRENT_PLAY_REDIRECT_DOMAINS=[
{"source": "*.legacy.example", "target": "legacy.example"},
{"source": "legacy.example", "target": "a.example"},
],
)
out = self.lookup.run(["*.legacy.example", "files.key"], variables=v)[0]
self.assertEqual(out, "/etc/letsencrypt/live/*.legacy.example/privkey.pem")
def test_letsencrypt_wildcard_flavor_keeps_the_app_lineage(self):
v = dict(self.vars, CERTBOT_FLAVOR="wildcard")
out = self.lookup.run(["alias-a.example", "files.cert"], variables=v)[0]
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
def test_letsencrypt_app_term_keeps_the_primary_lineage(self):
out = self.lookup.run(["web-app-a", "files.cert"], variables=self.vars)[0]
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
def test_letsencrypt_requires_the_certbot_flavor(self):
v = dict(self.vars)
del v["CERTBOT_FLAVOR"]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-a"], variables=v, mode="app")
def test_selfsigned_global_scope(self):
out = self.lookup.run(["web-app-b"], variables=self.vars, mode="app")[0]
self.assertEqual(out["mode"], "self_signed")
self.assertEqual(out["scope"], "global")
self.assertEqual(out["cert_id"], "global")
self.assertEqual(
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
)
self.assertEqual(
out["files"]["key"], "/etc/infinito.nexus/selfsigned/global/privkey.pem"
)
self.assertEqual(
out["domains"]["san"],
["b.example", "a.example", "b-alt.example", "c.example", "api.c.example"],
)
def test_selfsigned_global_scope_requires_current_play_domains_all_list(self):
v = dict(self.vars)
del v["CURRENT_PLAY_DOMAINS_ALL"]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v, mode="app")
v2 = dict(self.vars)
v2["CURRENT_PLAY_DOMAINS_ALL"] = {"a.example": True}
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v2, mode="app")
v3 = dict(self.vars)
v3["CURRENT_PLAY_DOMAINS_ALL"] = []
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v3, mode="app")
v4 = dict(self.vars)
v4["CURRENT_PLAY_DOMAINS_ALL"] = ["a.example", ""]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v4, mode="app")
v5 = dict(self.vars)
v5["CURRENT_PLAY_DOMAINS_ALL"] = ["a.example", 123]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v5, mode="app")
def test_selfsigned_app_scope_paths(self):
v = dict(self.vars)
v["TLS_SELFSIGNED_SCOPE"] = "app"
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
self.assertEqual(out["scope"], "app")
self.assertEqual(out["cert_id"], "web-app-b")
self.assertEqual(
out["files"]["cert"],
"/etc/infinito.nexus/selfsigned/web-app-b/b.example/fullchain.pem",
)
self.assertEqual(
out["files"]["key"],
"/etc/infinito.nexus/selfsigned/web-app-b/b.example/privkey.pem",
)
def test_primary_domain_term_matches_the_application_term(self):
cases = [
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="san")),
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="dedicated")),
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="wildcard")),
("web-app-b", "b.example", dict(self.vars)),
("web-app-b", "b.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")),
("web-app-c", "c.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")),
]
for app_id, primary, v in cases:
by_app = self.lookup.run([app_id, "files"], variables=v)[0]
by_domain = self.lookup.run([primary, "files"], variables=v)[0]
self.assertEqual(by_domain, by_app, (app_id, primary))
def test_selfsigned_app_scope_secondary_domain_uses_the_app_leaf(self):
v = dict(self.vars, TLS_SELFSIGNED_SCOPE="app")
cases = [
("b-alt.example", "/etc/infinito.nexus/selfsigned/web-app-b/b.example"),
("api.c.example", "/etc/infinito.nexus/selfsigned/web-app-c/c.example"),
]
for term, leaf in cases:
out = self.lookup.run([term, "files"], variables=v)[0]
self.assertEqual(out["cert"], f"{leaf}/fullchain.pem")
self.assertEqual(out["key"], f"{leaf}/privkey.pem")
def test_letsencrypt_secondary_domain_gets_its_own_lineage_link(self):
v = dict(
self.vars,
applications=dict(self.applications, **{"web-app-b": {}}),
)
out = self.lookup.run(["b-alt.example", "files.cert"], variables=v)[0]
self.assertEqual(out, "/etc/letsencrypt/live/b-alt.example/fullchain.pem")
def test_letsencrypt_onion_domain_keeps_the_app_lineage(self):
domains = dict(self.domains, **{"web-app-a": ["a.example", "a.node.onion"]})
for flavor in ("san", "dedicated", "wildcard"):
v = dict(self.vars, CERTBOT_FLAVOR=flavor, domains=domains)
out = self.lookup.run(["a.node.onion", "files.cert"], variables=v)[0]
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
def test_selfsigned_san_override(self):
v = dict(self.vars)
v["TLS_SELFSIGNED_SCOPE"] = "app"
out = self.lookup.run(["web-app-c"], variables=v, mode="app")[0]
self.assertEqual(out["domains"]["san"], ["c.example", "x.example"])
def test_off_mode_returns_empty_files_and_san(self):
v = dict(self.vars)
v["domains"] = dict(self.domains)
v["domains"]["web-app-off"] = "off.example"
out = self.lookup.run(["web-app-off"], variables=v, mode="app")[0]
self.assertEqual(out["enabled"], False)
self.assertEqual(out["mode"], "off")
self.assertEqual(out["files"]["cert"], "")
self.assertEqual(out["files"]["key"], "")
self.assertEqual(out["domains"]["san"], [])
def test_want_path(self):
val = self.lookup.run(
["web-app-a", "files.cert"], variables=self.vars, mode="app"
)[0]
self.assertEqual(val, "/etc/letsencrypt/live/a.example/fullchain.pem")
def test_missing_required_when_needed(self):
v = dict(self.vars)
del v["LETSENCRYPT_LIVE_PATH"]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-a"], variables=v, mode="app")
v2 = dict(self.vars)
v2["applications"] = dict(self.applications)
v2["applications"]["web-app-a"] = {"server": {"tls": {"flavor": "self_signed"}}}
del v2["TLS_SELFSIGNED_BASE_PATH"]
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-a"], variables=v2, mode="app")
def test_invalid_scope(self):
v = dict(self.vars)
v["TLS_SELFSIGNED_SCOPE"] = "nope"
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v, mode="app")
def test_invalid_tls_mode_default(self):
v = dict(self.vars)
v["TLS_MODE"] = "invalid"
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-a"], variables=v, mode="app")
def test_selfsigned_base_path_expands_jinja_from_hostvars(self):
"""
TLS_SELFSIGNED_BASE_PATH may contain Jinja markers and must be expanded
inside the lookup using the host context (hostvars[inventory_hostname]).
"""
v = dict(self.vars)
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
v["inventory_hostname"] = "localhost"
v["hostvars"] = {
"localhost": {
"SOFTWARE_DOMAIN": "infinito.nexus",
}
}
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
self.assertEqual(out["mode"], "self_signed")
self.assertEqual(
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
)
self.assertEqual(
out["files"]["key"], "/etc/infinito.nexus/selfsigned/global/privkey.pem"
)
def test_letsencrypt_live_path_expands_jinja_from_hostvars(self):
"""
LETSENCRYPT_LIVE_PATH may contain Jinja markers and must be expanded.
"""
v = dict(self.vars)
v["LETSENCRYPT_LIVE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/letsencrypt/live"
v["inventory_hostname"] = "localhost"
v["hostvars"] = {
"localhost": {
"SOFTWARE_DOMAIN": "infinito.nexus",
}
}
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
self.assertEqual(out["mode"], "letsencrypt")
self.assertEqual(
out["files"]["cert"],
"/etc/infinito.nexus/letsencrypt/live/a.example/fullchain.pem",
)
self.assertEqual(
out["files"]["key"],
"/etc/infinito.nexus/letsencrypt/live/a.example/privkey.pem",
)
def test_jinja_expansion_missing_var_fails_hard(self):
"""
With strict rendering, missing vars used inside the Jinja expression must
raise an AnsibleError (no leaking "{{ ... }}" into configs).
"""
v = dict(self.vars)
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
v["inventory_hostname"] = "localhost"
v["hostvars"] = {"localhost": {}}
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v, mode="app")
def test_jinja_expansion_uses_hostvars_over_lookup_vars(self):
"""
If both `variables` and hostvars contain SOFTWARE_DOMAIN, hostvars should win
(as implemented by ctx.update(hostvars[inventory_hostname])).
"""
v = dict(self.vars)
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
v["SOFTWARE_DOMAIN"] = "Wrong.Name"
v["inventory_hostname"] = "localhost"
v["hostvars"] = {
"localhost": {
"SOFTWARE_DOMAIN": "infinito.nexus",
}
}
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
self.assertEqual(
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
)
def test_jinja_expression_that_renders_to_empty_triggers_failfast(self):
"""
If Jinja renders to something that still contains Jinja markers, or is invalid,
cert should fail fast. Here we use a construction that still contains Jinja
after rendering by injecting literal braces.
"""
v = dict(self.vars)
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ '{{ STILL_JINJA }}' }}/selfsigned"
v["inventory_hostname"] = "localhost"
v["hostvars"] = {"localhost": {"SOFTWARE_DOMAIN": "infinito.nexus"}}
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-b"], variables=v, mode="app")
if __name__ == "__main__":
unittest.main()