Public Access
265 lines
8.9 KiB
Python
265 lines
8.9 KiB
Python
"""Unit tests for devx.tools.check_jinja_expr.
|
|
|
|
Verifies that the check correctly validates Jinja2 expressions,
|
|
catches reversed strftime filter arguments (the OBL-INFRA-508 bug),
|
|
and passes on valid expressions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from devx.tools.check_jinja_expr import (
|
|
_check_file,
|
|
_default_ansible_dirs,
|
|
_extract_expressions,
|
|
_render_expression,
|
|
main,
|
|
)
|
|
|
|
|
|
def test_render_valid_expression():
|
|
"""Valid Jinja expression renders without error."""
|
|
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime(1735689600)")
|
|
assert ok
|
|
|
|
|
|
def test_render_reversed_strftime_args():
|
|
"""Reversed strftime filter args are detected as an error."""
|
|
ok, msg = _render_expression("(now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00')")
|
|
assert not ok
|
|
assert "reversed" in msg.lower()
|
|
|
|
|
|
def test_render_correct_strftime_args():
|
|
"""Correct strftime filter args pass."""
|
|
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime((now().timestamp() | int) + 3600)")
|
|
assert ok
|
|
|
|
|
|
def test_render_unknown_filter():
|
|
"""Unknown filter is reported as an error."""
|
|
ok, msg = _render_expression("'test' | nonexistent_filter")
|
|
assert not ok
|
|
assert "filter" in msg.lower()
|
|
|
|
|
|
def test_extract_skips_go_templates():
|
|
"""Go template syntax ({{.Field}}) is not extracted."""
|
|
content = "cmd: docker inspect --format '{{.State.Running}}' container"
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_single_char():
|
|
"""Single-character fragments are not extracted."""
|
|
content = 'value: "{{ \' }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_multiline():
|
|
"""Multi-line expressions are skipped."""
|
|
content = 'value: "{{\n something\n}}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_unbalanced():
|
|
"""Expressions with unbalanced braces (from partial capture) are skipped."""
|
|
content = "value: \"{{ default({'k': {}}, true) }}\""
|
|
expressions = _extract_expressions(content)
|
|
# The regex captures {{ default({'k': {}} — unbalanced parens
|
|
# because the inner }} terminates the match early.
|
|
# All extracted expressions should have balanced braces.
|
|
for expr in expressions:
|
|
assert expr.count("{") == expr.count("}")
|
|
|
|
|
|
def test_extract_valid_expression():
|
|
"""Valid Jinja expressions are extracted."""
|
|
content = "value: \"{{ my_var | default('x') }}\""
|
|
expressions = _extract_expressions(content)
|
|
assert "my_var | default('x')" in expressions
|
|
|
|
|
|
def test_main_passes_on_clean_file(tmp_path: Path) -> None:
|
|
"""A file with valid expressions passes."""
|
|
test_file = tmp_path / "tasks.yml"
|
|
test_file.write_text("value: \"{{ my_var | default('x') }}\"\nother: \"{{ '%Y' | strftime(1735689600) }}\"\n")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--path", str(test_file)])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_main_no_violations_empty_dir(tmp_path: Path) -> None:
|
|
"""An empty directory passes."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--path", str(tmp_path)])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_main_catches_reversed_strftime(tmp_path: Path) -> None:
|
|
"""A file with reversed strftime args is flagged."""
|
|
test_file = tmp_path / "test.yml"
|
|
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--path", str(test_file)])
|
|
assert result.exit_code == 1
|
|
assert "reversed" in result.output.lower()
|
|
|
|
|
|
def test_render_skips_undefined_var():
|
|
"""Undefined variables are skipped (MockDict returns mock for missing keys)."""
|
|
ok, _ = _render_expression("nonexistent_var_in_mock | upper")
|
|
assert ok
|
|
|
|
|
|
def test_render_skips_other_errors():
|
|
"""Non-filter errors from missing mocks are skipped."""
|
|
ok, _ = _render_expression("some_undefined.attr.method()")
|
|
assert ok
|
|
|
|
|
|
def test_extract_skips_backtick():
|
|
"""Backtick fragments are skipped (caught by single-char check)."""
|
|
content = 'value: "{{ ` }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_network_settings():
|
|
"""Expressions with .NetworkSettings. patterns are skipped."""
|
|
content = 'value: "{{ foo.NetworkSettings.IPAddress }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_unbalanced_parens():
|
|
"""Expressions with unbalanced parens are skipped."""
|
|
content = 'value: "{{ foo(bar }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_unbalanced_braces():
|
|
"""Expressions with unbalanced braces are skipped."""
|
|
content = 'value: "{{ foo{bar }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_unbalanced_brackets():
|
|
"""Expressions with unbalanced brackets are skipped."""
|
|
content = 'value: "{{ foo[0 }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_control_flow():
|
|
"""Control flow fragments starting with % are skipped."""
|
|
content = 'value: "{{ % if x }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_check_file_outside_repo(tmp_path: Path) -> None:
|
|
"""Files outside REPO_ROOT are handled (no relative_to error)."""
|
|
test_file = tmp_path / "test.yml"
|
|
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
|
violations = _check_file(test_file, Path("/other/repo"))
|
|
assert len(violations) == 1
|
|
assert "reversed" in violations[0].lower()
|
|
|
|
|
|
def test_render_mock_dict_missing_key():
|
|
"""MockDict returns a mock for missing keys (no UndefinedError)."""
|
|
ok, _ = _render_expression("undefined_var.some_attr | upper")
|
|
assert ok
|
|
|
|
|
|
def test_render_syntax_error():
|
|
"""Syntax errors are reported as failures."""
|
|
ok, msg = _render_expression("{{ invalid syntax +")
|
|
assert not ok
|
|
assert "Syntax error" in msg
|
|
|
|
|
|
def test_render_unknown_filter_error():
|
|
"""Unknown filters are reported as failures (not skipped)."""
|
|
ok, msg = _render_expression("'test' | nonexistent_filter")
|
|
assert not ok
|
|
assert "filter" in msg.lower()
|
|
|
|
|
|
def test_render_generic_exception_skipped():
|
|
"""Non-filter exceptions from missing mocks are skipped."""
|
|
# replace() with no args triggers TypeError (missing required args)
|
|
# which is not a filter-not-found or strftime error — should be skipped.
|
|
ok, msg = _render_expression("my_var | replace")
|
|
assert ok
|
|
assert "Skipped" in msg
|
|
|
|
|
|
def test_default_ansible_dirs():
|
|
"""_default_ansible_dirs returns playbooks and roles paths."""
|
|
dirs = _default_ansible_dirs()
|
|
assert Path.cwd() / "ansible" / "playbooks" in dirs
|
|
assert Path.cwd() / "ansible" / "roles" in dirs
|
|
|
|
|
|
def test_main_default_dirs(tmp_path: Path) -> None:
|
|
"""Running with no --path scans default dirs (uses small temp fixture)."""
|
|
(tmp_path / "playbooks").mkdir()
|
|
(tmp_path / "roles").mkdir()
|
|
(tmp_path / "playbooks" / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
|
with patch(
|
|
"devx.tools.check_jinja_expr._default_ansible_dirs",
|
|
return_value=[tmp_path / "playbooks", tmp_path / "roles"],
|
|
):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, [])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_main_custom_ansible_dirs(tmp_path: Path) -> None:
|
|
"""--ansible-dir option works."""
|
|
(tmp_path / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_extract_skips_println():
|
|
"""Expressions with 'println' (Go template) are skipped."""
|
|
content = 'value: "{{ println something }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_extract_skips_state_dot():
|
|
"""Expressions with .State. patterns are skipped."""
|
|
content = 'value: "{{ foo.State.Running }}"'
|
|
expressions = _extract_expressions(content)
|
|
assert len(expressions) == 0
|
|
|
|
|
|
def test_render_now_with_format():
|
|
"""now() with a format argument works."""
|
|
ok, _ = _render_expression("now('%Y-%m-%d')")
|
|
assert ok
|
|
|
|
|
|
def test_find_yaml_files_skips_molecule(tmp_path: Path) -> None:
|
|
"""Molecule directories are excluded from file search."""
|
|
from devx.tools.check_jinja_expr import _find_yaml_files
|
|
|
|
(tmp_path / "tasks.yml").write_text("value: test\n")
|
|
(tmp_path / "molecule").mkdir()
|
|
(tmp_path / "molecule" / "test.yml").write_text("value: test\n")
|
|
files = _find_yaml_files(tmp_path)
|
|
assert all("molecule" not in f.parts for f in files)
|