mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-29 23:08:06 +02:00
Solved missing logout injection bug and refactored srv-web-7-7-inj-compose
This commit is contained in:
@@ -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_<var>" or "defaults_<var>").
|
||||
"""
|
||||
|
||||
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_<var>/defaults_<var>).
|
||||
"""
|
||||
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()
|
||||
unittest.main()
|
||||
|
0
tests/unit/roles/srv-web-inj-compose/__init__.py
Normal file
0
tests/unit/roles/srv-web-inj-compose/__init__.py
Normal file
@@ -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()
|
Reference in New Issue
Block a user