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_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()