From 3b4821f7e72f9cc8d02f448f1f9281ce05c9216f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 15 Aug 2025 23:55:19 +0200 Subject: [PATCH] Solved missing logout injection bug and refactored srv-web-7-7-inj-compose --- .../tasks/01_cloudflare.yml | 2 +- .../tasks/flavors/dedicated.yml | 4 +- roles/srv-web-6-6-tls-core/tasks/main.yml | 2 +- roles/srv-web-7-6-composer/tasks/main.yml | 4 +- roles/srv-web-7-7-inj-compose/__init__.py | 0 .../filter_plugins/__init__.py | 0 .../filter_plugins/inj_enabled.py | 35 +++ roles/srv-web-7-7-inj-compose/tasks/main.yml | 25 +- .../templates/location.lua.j2 | 30 +-- roles/srv-web-7-7-inj-compose/vars/main.yml | 9 +- roles/srv-web-7-7-inj-matomo/tasks/main.yml | 2 +- roles/srv-web-7-7-inj-matomo/vars/main.yml | 2 +- roles/web-app-discourse/handlers/main.yml | 3 +- roles/web-app-discourse/tasks/03_docker.yml | 19 +- .../tasks/redirect-domain.yml | 2 +- .../integration/test_variable_definitions.py | 228 ++++++++++++------ .../roles/srv-web-inj-compose/__init__.py | 0 .../filter_plugins/__init__.py | 0 .../filter_plugins/test_inj_enabled.py | 94 ++++++++ 19 files changed, 343 insertions(+), 118 deletions(-) create mode 100644 roles/srv-web-7-7-inj-compose/__init__.py create mode 100644 roles/srv-web-7-7-inj-compose/filter_plugins/__init__.py create mode 100644 roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py create mode 100644 tests/unit/roles/srv-web-inj-compose/__init__.py create mode 100644 tests/unit/roles/srv-web-inj-compose/filter_plugins/__init__.py create mode 100644 tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py diff --git a/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml b/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml index 4a5cb1d7..2d2dad64 100644 --- a/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml +++ b/roles/srv-proxy-6-6-domain/tasks/01_cloudflare.yml @@ -9,7 +9,7 @@ cf_zone_id: "{{ (cf_zone_ids | default({})).get(domain | to_primary_domain, false) }}" # Only look up from Cloudflare if we still don't have it -- name: "Ensure Cloudflare Zone ID is known for {{ domain }}" +- name: "Ensure Cloudflare Zone ID is known for '{{ domain }}'" vars: cf_api_url: "https://api.cloudflare.com/client/v4/zones" ansible.builtin.uri: diff --git a/roles/srv-web-6-6-tls-core/tasks/flavors/dedicated.yml b/roles/srv-web-6-6-tls-core/tasks/flavors/dedicated.yml index 0f3148ff..d3c0ee10 100644 --- a/roles/srv-web-6-6-tls-core/tasks/flavors/dedicated.yml +++ b/roles/srv-web-6-6-tls-core/tasks/flavors/dedicated.yml @@ -1,10 +1,10 @@ -- name: "Check if certificate already exists for {{ domain }}" +- name: "Check if certificate already exists for '{{ domain }}'" cert_check_exists: domain: "{{ domain }}" cert_base_path: "{{ LETSENCRYPT_LIVE_PATH }}" register: cert_check -- name: "receive certificate for {{ domain }}" +- name: "receive certificate for '{{ domain }}'" command: >- certbot certonly --agree-tos diff --git a/roles/srv-web-6-6-tls-core/tasks/main.yml b/roles/srv-web-6-6-tls-core/tasks/main.yml index cd7395ea..fed011c1 100644 --- a/roles/srv-web-6-6-tls-core/tasks/main.yml +++ b/roles/srv-web-6-6-tls-core/tasks/main.yml @@ -9,7 +9,7 @@ - name: "Include flavor '{{ CERTBOT_FLAVOR }}' for '{{ domain }}'" include_tasks: "{{ role_path }}/tasks/flavors/{{ CERTBOT_FLAVOR }}.yml" -#- name: "Cleanup dedicated cert for {{ domain }}" +#- name: "Cleanup dedicated cert for '{{ domain }}'" # command: >- # certbot delete --cert-name {{ domain }} --non-interactive # when: diff --git a/roles/srv-web-7-6-composer/tasks/main.yml b/roles/srv-web-7-6-composer/tasks/main.yml index e261978f..fb2c9aea 100644 --- a/roles/srv-web-7-6-composer/tasks/main.yml +++ b/roles/srv-web-7-6-composer/tasks/main.yml @@ -1,9 +1,9 @@ # run_once_srv_web_7_6_composer: deactivated -- name: "include role srv-web-7-7-inj-compose for {{ domain }}" +- name: "include role srv-web-7-7-inj-compose for '{{ domain }}'" include_role: name: srv-web-7-7-inj-compose -- name: "include role srv-web-6-6-tls-core for {{ domain }}" +- name: "include role srv-web-6-6-tls-core for '{{ domain }}'" include_role: name: srv-web-6-6-tls-core diff --git a/roles/srv-web-7-7-inj-compose/__init__.py b/roles/srv-web-7-7-inj-compose/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/srv-web-7-7-inj-compose/filter_plugins/__init__.py b/roles/srv-web-7-7-inj-compose/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py b/roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py new file mode 100644 index 00000000..1b9e6b1a --- /dev/null +++ b/roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py @@ -0,0 +1,35 @@ +# roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py +# +# Usage in tasks: +# - set_fact: +# inj_enabled: "{{ applications | inj_enabled(application_id, ['javascript','logout','css','matomo','desktop']) }}" + +import sys +import os + +# allow imports from module_utils (same trick as your get_app_conf filter) +base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +mu = os.path.join(base, 'module_utils') +for p in (base, mu): + if p not in sys.path: + sys.path.insert(0, p) + +from module_utils.config_utils import get_app_conf + +def inj_enabled_filter(applications, application_id, features, prefix="features", default=False): + """ + Build a dict {feature: value} by reading the feature flags under the given prefix for the selected application. + Uses get_app_conf with strict=False so missing keys just return the default. + """ + result = {} + for f in features: + path = f"{prefix}.{f}" if prefix else f + result[f] = get_app_conf(applications, application_id, path, strict=False, default=default) + return result + + +class FilterModule(object): + def filters(self): + return { + "inj_enabled": inj_enabled_filter, + } diff --git a/roles/srv-web-7-7-inj-compose/tasks/main.yml b/roles/srv-web-7-7-inj-compose/tasks/main.yml index 09a8382b..b5defe3c 100644 --- a/roles/srv-web-7-7-inj-compose/tasks/main.yml +++ b/roles/srv-web-7-7-inj-compose/tasks/main.yml @@ -1,11 +1,6 @@ -- name: Set inj_enabled dictionary +- name: Build inj_enabled set_fact: - inj_enabled: - javascript: "{{ applications | get_app_conf(application_id, 'features.javascript', False) }}" - logout: "{{ (applications | get_app_conf(application_id, 'features.logout', False) or domain == PRIMARY_DOMAIN) }}" - css: "{{ applications | get_app_conf(application_id, 'features.css', False) }}" - matomo: "{{ applications | get_app_conf(application_id, 'features.matomo', False) }}" - desktop: "{{ applications | get_app_conf(application_id, 'features.desktop', False) }}" + inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}" - block: - name: Include dependency 'srv-web-7-4-core' @@ -15,13 +10,13 @@ - include_tasks: utils/run_once.yml when: run_once_srv_web_7_7_inj_compose is not defined -- name: "Activate Portfolio iFrame notifier for {{ domain }}" +- name: "Activate Portfolio iFrame notifier for '{{ domain }}'" include_role: name: srv-web-7-7-inj-desktop public: true # Vars used in templates when: inj_enabled.desktop -- name: "Load CDN for {{ domain }}" +- name: "Load CDN for '{{ domain }}'" include_role: name: web-svc-cdn public: false @@ -41,24 +36,28 @@ vars: handler_role_name: "{{ item }}" -- name: "Activate Corporate CSS for {{ domain }}" +- name: Reinitialize 'inj_enabled' for '{{ domain }}', after modification by CDN + set_fact: + inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}" + +- name: "Activate Corporate CSS for '{{ domain }}'" include_role: name: srv-web-7-7-inj-css when: - inj_enabled.css - run_once_srv_web_7_7_inj_css is not defined -- name: "Activate Matomo Tracking for {{ domain }}" +- name: "Activate Matomo Tracking for '{{ domain }}'" include_role: name: srv-web-7-7-inj-matomo when: inj_enabled.matomo -- name: "Activate Javascript for {{ domain }}" +- name: "Activate Javascript for '{{ domain }}'" include_role: name: srv-web-7-7-inj-javascript when: inj_enabled.javascript -- name: "Activate logout proxy for {{ domain }}" +- name: "Activate logout proxy for '{{ domain }}'" include_role: name: srv-web-7-7-inj-logout public: true # Vars used in templates diff --git a/roles/srv-web-7-7-inj-compose/templates/location.lua.j2 b/roles/srv-web-7-7-inj-compose/templates/location.lua.j2 index 5eb9468e..2a141b20 100644 --- a/roles/srv-web-7-7-inj-compose/templates/location.lua.j2 +++ b/roles/srv-web-7-7-inj-compose/templates/location.lua.j2 @@ -1,3 +1,17 @@ +{% macro push_snippets(list_name, features) -%} +{% for f in features -%} +{% if inj_enabled.get(f) -%} +{{ list_name }}[#{{ list_name }} + 1] = [=[ + {%- include + 'roles/srv-web-7-7-inj-' ~ f ~ + '/templates/' ~ + ('head' if list_name == 'head_snippets' else 'body') ~ + '_sub.j2' + -%} +]=] +{% endif -%} +{% endfor -%} +{%- endmacro %} lua_need_request_body on; @@ -43,13 +57,7 @@ body_filter_by_lua_block { -- build a list of head-injection snippets local head_snippets = {} - {% for head_feature in ['css', 'matomo', 'desktop', 'javascript', 'logout' ] %} - {% if applications | get_app_conf(application_id, 'features.' ~ head_feature, false) %} - head_snippets[#head_snippets + 1] = [=[ - {%- include "roles/srv-web-7-7-inj-" ~ head_feature ~ "/templates/head_sub.j2" -%} - ]=] - {% endif %} - {% endfor %} + {{ push_snippets('head_snippets', ['css','matomo','desktop','javascript','logout']) }} -- inject all collected snippets right before local head_payload = table.concat(head_snippets, "\n") .. "" @@ -58,13 +66,7 @@ body_filter_by_lua_block { -- build a list of body-injection snippets local body_snippets = {} - {% for body_feature in ['matomo', 'logout', 'desktop'] %} - {% if applications | get_app_conf(application_id, 'features.' ~ body_feature, false) %} - body_snippets[#body_snippets + 1] = [=[ - {%- include "roles/srv-web-7-7-inj-" ~ body_feature ~ "/templates/body_sub.j2" -%} - ]=] - {% endif %} - {% endfor %} + {{ push_snippets('body_snippets', ['matomo','logout','desktop']) }} -- inject all collected snippets right before local body_payload = table.concat(body_snippets, "\n") .. "" diff --git a/roles/srv-web-7-7-inj-compose/vars/main.yml b/roles/srv-web-7-7-inj-compose/vars/main.yml index 0bde9984..ad0ff388 100644 --- a/roles/srv-web-7-7-inj-compose/vars/main.yml +++ b/roles/srv-web-7-7-inj-compose/vars/main.yml @@ -1,2 +1,9 @@ # Docker -docker_pull_git_repository: false # Deactivated here to don't inhire this \ No newline at end of file +docker_pull_git_repository: false # Deactivated here to don't inhire this + +SRV_WEB_INJ_COMP_FEATURES_ALL: + - 'javascript' + - 'logout' + - 'css' + - 'matomo' + - 'desktop' \ No newline at end of file diff --git a/roles/srv-web-7-7-inj-matomo/tasks/main.yml b/roles/srv-web-7-7-inj-matomo/tasks/main.yml index 8550516c..6e18c6ba 100644 --- a/roles/srv-web-7-7-inj-matomo/tasks/main.yml +++ b/roles/srv-web-7-7-inj-matomo/tasks/main.yml @@ -37,7 +37,7 @@ uri: url: "{{ matomo_index_php_url }}" method: POST - body: "module=API&method=SitesManager.addSite&siteName={{ base_domain }}&urls=https://{{ base_domain }}&token_auth={{ matomo_auth_token }}&format=json" + body: "module=API&method=SitesManager.addSite&siteName={{ base_domain }}&urls={{ WEB_PROTOCOL }}://{{ base_domain }}&token_auth={{ matomo_auth_token }}&format=json" body_format: form-urlencoded status_code: 200 return_content: yes diff --git a/roles/srv-web-7-7-inj-matomo/vars/main.yml b/roles/srv-web-7-7-inj-matomo/vars/main.yml index ee68d463..6c14ce9d 100644 --- a/roles/srv-web-7-7-inj-matomo/vars/main.yml +++ b/roles/srv-web-7-7-inj-matomo/vars/main.yml @@ -1,4 +1,4 @@ base_domain: "{{ domain | regex_replace('^(?:.*\\.)?(.+\\..+)$', '\\1') }}" matomo_index_php_url: "{{ domains | get_url('web-app-matomo', WEB_PROTOCOL) }}/index.php" matomo_auth_token: "{{ applications['web-app-matomo'].credentials.auth_token }}" -matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url=https://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}" \ No newline at end of file +matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url={{ WEB_PROTOCOL }}://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}" \ No newline at end of file diff --git a/roles/web-app-discourse/handlers/main.yml b/roles/web-app-discourse/handlers/main.yml index 4c0b5175..6c0480d3 100644 --- a/roles/web-app-discourse/handlers/main.yml +++ b/roles/web-app-discourse/handlers/main.yml @@ -21,4 +21,5 @@ args: executable: /bin/bash chdir: "{{ DISCOURSE_REPOSITORY_DIR }}" - listen: recreate discourse \ No newline at end of file + listen: recreate discourse + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" \ No newline at end of file diff --git a/roles/web-app-discourse/tasks/03_docker.yml b/roles/web-app-discourse/tasks/03_docker.yml index 0a8afe35..f27305ed 100644 --- a/roles/web-app-discourse/tasks/03_docker.yml +++ b/roles/web-app-discourse/tasks/03_docker.yml @@ -33,11 +33,20 @@ notify: recreate discourse - name: "Verify that '{{ DISCOURSE_CONTAINER }}' is running" - command: docker compose ps --filter status=running --format '{{"{{"}}.Name{{"}}"}}' | grep -x {{ DISCOURSE_CONTAINER }} - register: docker_ps - changed_when: docker_ps.rc == 1 - failed_when: docker_ps.rc not in [0, 1] - notify: recreate discourse + ansible.builtin.command: + argv: + - docker + - ps + - --filter + - "name=^{{ DISCOURSE_CONTAINER }}$" + - --filter + - status=running + - --format + - "{{ '{{.Names}}' }}" + register: docker_ps + changed_when: docker_ps.stdout.strip() == "" + failed_when: docker_ps.rc != 0 + notify: recreate discourse - name: flush, to recreate discourse app meta: flush_handlers diff --git a/roles/web-opt-rdr-domains/tasks/redirect-domain.yml b/roles/web-opt-rdr-domains/tasks/redirect-domain.yml index 2a415d6f..42b72696 100644 --- a/roles/web-opt-rdr-domains/tasks/redirect-domain.yml +++ b/roles/web-opt-rdr-domains/tasks/redirect-domain.yml @@ -2,7 +2,7 @@ include_role: name: srv-web-6-6-tls-core -- name: "Deploying NGINX redirect configuration for {{ domain }}" +- name: "Deploying NGINX redirect configuration for '{{ domain }}'" template: src: redirect.domain.nginx.conf.j2 dest: "{{ NGINX.DIRECTORIES.HTTP.SERVERS }}{{ domain }}.conf" diff --git a/tests/integration/test_variable_definitions.py b/tests/integration/test_variable_definitions.py index cb8db255..6874a5a6 100644 --- a/tests/integration/test_variable_definitions.py +++ b/tests/integration/test_variable_definitions.py @@ -4,13 +4,24 @@ import yaml import re from glob import glob + class TestVariableDefinitions(unittest.TestCase): + """ + Ensures that every Jinja2 variable used in templates/playbooks is defined + somewhere in the repository (direct var files, set_fact/vars blocks, + loop_var/register names, Jinja set/for definitions, and Jinja macro parameters). + + If a variable is not defined, the test passes only if a corresponding + fallback key exists (either "default_" or "defaults_"). + """ + def setUp(self): - # Project root + # Project root = repo root (tests/integration/.. -> ../../) self.project_root = os.path.abspath( os.path.join(os.path.dirname(__file__), '../../') ) - # Gather all definition files recursively under vars/ and defaults/, plus group_vars/all + + # Collect all variable definition files: roles/*/{vars,defaults}/**/*.yml and group_vars/all/*.yml self.var_files = [] patterns = [ os.path.join(self.project_root, 'roles', '*', 'vars', '**', '*.yml'), @@ -20,20 +31,41 @@ class TestVariableDefinitions(unittest.TestCase): for pat in patterns: self.var_files.extend(glob(pat, recursive=True)) - # Valid file extensions to scan for definitions and usages + # File extensions to scan for Jinja usage/inline definitions self.scan_extensions = {'.yml', '.j2'} + # ----------------------- # Regex patterns + # ----------------------- + + # Simple {{ var }} usage with optional Jinja filters after a pipe self.simple_var_pattern = re.compile(r"{{\s*([a-zA-Z_]\w*)\s*(?:\|[^}]*)?}}") + + # {% set var = ... %} self.jinja_set_def = re.compile(r'{%\s*-?\s*set\s+([a-zA-Z_]\w*)\s*=') - self.jinja_for_def = re.compile(r'{%\s*-?\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in') + + # {% for x in ... %} or {% for k, v in ... %} + self.jinja_for_def = re.compile( + r'{%\s*-?\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in' + ) + + # {% macro name(param1, param2=..., *varargs, **kwargs) %} + self.jinja_macro_def = re.compile( + r'{%\s*-?\s*macro\s+[a-zA-Z_]\w*\s*\((.*?)\)\s*-?%}' + ) + + # Ansible YAML anchors for inline var declarations self.ansible_set_fact = re.compile(r'^(?:\s*[-]\s*)?set_fact\s*:\s*$') self.ansible_vars_block = re.compile(r'^(?:\s*[-]\s*)?vars\s*:\s*$') self.ansible_loop_var = re.compile(r'^\s*loop_var\s*:\s*([a-zA-Z_]\w*)') self.mapping_key = re.compile(r'^\s*([a-zA-Z_]\w*)\s*:\s*') - # Initialize defined set from var files + # ----------------------- + # Collect "defined" names + # ----------------------- self.defined = set() + + # 1) Keys from var files (top-level dict keys) for vf in self.var_files: try: with open(vf, 'r', encoding='utf-8') as f: @@ -41,9 +73,10 @@ class TestVariableDefinitions(unittest.TestCase): if isinstance(data, dict): self.defined.update(data.keys()) except Exception: + # Ignore unreadable/invalid YAML files pass - # Phase 1: scan all files to collect inline definitions + # 2) Inline definitions across all scanned files for root, _, files in os.walk(self.project_root): for fn in files: ext = os.path.splitext(fn)[1] @@ -51,91 +84,136 @@ class TestVariableDefinitions(unittest.TestCase): continue path = os.path.join(root, fn) + in_set_fact = False set_fact_indent = 0 in_vars_block = False vars_block_indent = 0 - with open(path, 'r', encoding='utf-8', errors='ignore') as f: - for line in f: - stripped = line.lstrip() - indent = len(line) - len(stripped) - # set_fact keys - if self.ansible_set_fact.match(stripped): - in_set_fact = True - set_fact_indent = indent - continue - if in_set_fact: - if indent > set_fact_indent and stripped.strip(): - m = self.mapping_key.match(stripped) - if m: - self.defined.add(m.group(1)) - continue - else: - in_set_fact = False - # vars block keys - if self.ansible_vars_block.match(stripped): - in_vars_block = True - vars_block_indent = indent - continue - if in_vars_block: - # skip blank lines within vars block - if not stripped.strip(): - continue - if indent > vars_block_indent: - m = self.mapping_key.match(stripped) - if m: - self.defined.add(m.group(1)) - continue - else: - in_vars_block = False - # loop_var - m_loop = self.ansible_loop_var.match(stripped) - if m_loop: - self.defined.add(m_loop.group(1)) - # register - m_reg = re.match(r'^\s*register\s*:\s*([a-zA-Z_]\w*)', stripped) - if m_reg: - self.defined.add(m_reg.group(1)) - # jinja set - for m in self.jinja_set_def.finditer(line): - self.defined.add(m.group(1)) - # jinja for - for m in self.jinja_for_def.finditer(line): - self.defined.add(m.group(1)) - if m.group(2): - self.defined.add(m.group(2)) + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # --- set_fact block keys + if self.ansible_set_fact.match(stripped): + in_set_fact = True + set_fact_indent = indent + continue + if in_set_fact: + # Still inside set_fact child mapping? + if indent > set_fact_indent and stripped.strip(): + m = self.mapping_key.match(stripped) + if m: + self.defined.add(m.group(1)) + continue + else: + in_set_fact = False + + # --- vars: block keys + if self.ansible_vars_block.match(stripped): + in_vars_block = True + vars_block_indent = indent + continue + if in_vars_block: + # Ignore blank lines inside vars block + if not stripped.strip(): + continue + # Still inside vars child mapping? + if indent > vars_block_indent: + m = self.mapping_key.match(stripped) + if m: + self.defined.add(m.group(1)) + continue + else: + in_vars_block = False + + # --- loop_var + m_loop = self.ansible_loop_var.match(stripped) + if m_loop: + self.defined.add(m_loop.group(1)) + + # --- register: name + m_reg = re.match(r'^\s*register\s*:\s*([a-zA-Z_]\w*)', stripped) + if m_reg: + self.defined.add(m_reg.group(1)) + + # --- {% set var = ... %} + for m in self.jinja_set_def.finditer(line): + self.defined.add(m.group(1)) + + # --- {% for x [ , y ] in ... %} + for m in self.jinja_for_def.finditer(line): + self.defined.add(m.group(1)) + if m.group(2): + self.defined.add(m.group(2)) + + # --- {% macro name(params...) %} -> collect parameter names + for m in self.jinja_macro_def.finditer(line): + params_blob = m.group(1) + # Split by comma at top level (macros don't support nested tuples in params) + params = [p.strip() for p in params_blob.split(',')] + for p in params: + if not p: + continue + # Strip * / ** for varargs/kwargs + p = p.lstrip('*') + # Drop default value part: name=... + name = p.split('=', 1)[0].strip() + if re.match(r'^[a-zA-Z_]\w*$', name): + self.defined.add(name) + except Exception: + # Ignore unreadable files + pass def test_all_used_vars_are_defined(self): + """ + Scan all template/YAML files for {{ var }} usage and fail if a variable + is not known as defined and has no fallback keys (default_/defaults_). + """ undefined_uses = [] - # Phase 2: scan all files for usages + for root, _, files in os.walk(self.project_root): for fn in files: ext = os.path.splitext(fn)[1] if ext not in self.scan_extensions: continue + path = os.path.join(root, fn) - with open(path, 'r', encoding='utf-8', errors='ignore') as f: - for lineno, line in enumerate(f, 1): - for m in self.simple_var_pattern.finditer(line): - var = m.group(1) - # skip builtins and whitelisted names - if var in ('lookup', 'role_name', 'domains', 'item', 'host_type', - 'inventory_hostname', 'role_path', 'playbook_dir', - 'ansible_become_password', 'inventory_dir', 'ansible_memtotal_mb'): - continue - # skip defaults_var fallback - if var not in self.defined and \ - f"default_{var}" not in self.defined and \ - f"defaults_{var}" not in self.defined: - undefined_uses.append( - f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined" - ) + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for lineno, line in enumerate(f, 1): + for m in self.simple_var_pattern.finditer(line): + var = m.group(1) + + # Skip well-known Jinja/Ansible builtins and frequent loop aliases + if var in ( + 'lookup', 'role_name', 'domains', 'item', 'host_type', + 'inventory_hostname', 'role_path', 'playbook_dir', + 'ansible_become_password', 'inventory_dir', 'ansible_memtotal_mb' + ): + continue + + # Accept if defined directly or via fallback defaults + if ( + var not in self.defined + and f"default_{var}" not in self.defined + and f"defaults_{var}" not in self.defined + ): + undefined_uses.append( + f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined" + ) + except Exception: + # Ignore unreadable files + pass + if undefined_uses: self.fail( - "Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n" + - "\n".join(undefined_uses) + "Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n" + + "\n".join(undefined_uses) ) + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/unit/roles/srv-web-inj-compose/__init__.py b/tests/unit/roles/srv-web-inj-compose/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/srv-web-inj-compose/filter_plugins/__init__.py b/tests/unit/roles/srv-web-inj-compose/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py b/tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py new file mode 100644 index 00000000..f73839b5 --- /dev/null +++ b/tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py @@ -0,0 +1,94 @@ +# tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py +import importlib.util +from importlib import import_module +from pathlib import Path +import sys +import unittest + +THIS_FILE = Path(__file__) + +def find_repo_root(start: Path) -> Path: + target_rel = Path("roles") / "srv-web-7-7-inj-compose" / "filter_plugins" / "inj_enabled.py" + cur = start + for _ in range(12): + if (cur / target_rel).is_file(): + return cur + cur = cur.parent + return start.parents[6] + +REPO_ROOT = find_repo_root(THIS_FILE) +PLUGIN_PATH = REPO_ROOT / "roles" / "srv-web-7-7-inj-compose" / "filter_plugins" / "inj_enabled.py" + +# Ensure 'module_utils' is importable under its canonical package name +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +# Import the same module path the plugin uses +mu_mod = import_module("module_utils.config_utils") +AppConfigKeyError = mu_mod.AppConfigKeyError + +# Load inj_enabled filter plugin from file +spec = importlib.util.spec_from_file_location("inj_enabled", str(PLUGIN_PATH)) +inj_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inj_mod) # type: ignore +FilterModule = inj_mod.FilterModule + + +def _get_filter(): + fm = FilterModule() + flt = fm.filters().get("inj_enabled") + assert callable(flt), "inj_enabled filter not found or not callable" + return flt + + +class TestInjEnabledFilter(unittest.TestCase): + def setUp(self): + self.filter = _get_filter() + + def test_basic_build(self): + applications = { + "myapp": {"features": { + "javascript": True, "logout": False, "css": True, "matomo": False, "desktop": True + }} + } + features = ["javascript", "logout", "css", "matomo", "desktop"] + result = self.filter(applications, "myapp", features) + self.assertEqual(result, { + "javascript": True, "logout": False, "css": True, "matomo": False, "desktop": True + }) + + def test_missing_keys_return_default_false(self): + applications = {"app": {"features": {"javascript": True}}} + result = self.filter(applications, "app", ["javascript", "logout", "css"], default=False) + self.assertEqual(result["javascript"], True) + self.assertEqual(result["logout"], False) + self.assertEqual(result["css"], False) + + def test_default_true_applied_to_missing(self): + applications = {"app": {"features": {}}} + result = self.filter(applications, "app", ["logout", "css"], default=True) + self.assertEqual(result, {"logout": True, "css": True}) + + def test_custom_prefix(self): + applications = {"app": {"flags": {"logout": True, "css": False}}} + result = self.filter(applications, "app", ["logout", "css"], prefix="flags", default=False) + self.assertEqual(result, {"logout": True, "css": False}) + + def test_missing_application_id_raises(self): + applications = {"other": {"features": {"logout": True}}} + with self.assertRaises(AppConfigKeyError): + _ = self.filter(applications, "unknown-app", ["logout"]) + + def test_truthy_string_is_returned_as_is(self): + applications = {"app": {"features": {"logout": "true"}}} + result = self.filter(applications, "app", ["logout"], default=False) + self.assertEqual(result["logout"], "true") + + def test_nonexistent_feature_path_uses_default(self): + applications = {"app": {"features": {}}} + result = self.filter(applications, "app", ["nonexistent"], default=False) + self.assertEqual(result["nonexistent"], False) + + +if __name__ == "__main__": + unittest.main()