Public Access
341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""Unit tests for devx.tools.check_ansible_patterns."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from devx.tools.check_ansible_patterns import (
|
|
_check_file,
|
|
_check_task,
|
|
_check_tasks,
|
|
_find_task_files,
|
|
_is_legitimate_devnull,
|
|
_is_legitimate_or_true,
|
|
main,
|
|
)
|
|
|
|
|
|
def _make_task(name: str, action: str, value: str, **extra: object) -> dict:
|
|
"""Build a minimal task dict for testing."""
|
|
task: dict = {"name": name, action: value}
|
|
task.update(extra)
|
|
return task
|
|
|
|
|
|
class TestIsLegitimateOrTrue:
|
|
def test_cleanup_task_name_is_legitimate(self):
|
|
assert _is_legitimate_or_true("docker rm old-container", "Remove old container")
|
|
|
|
def test_prune_task_name_is_legitimate(self):
|
|
assert _is_legitimate_or_true("docker image prune -f", "Prune unused images")
|
|
|
|
def test_docker_rm_command_is_legitimate(self):
|
|
assert _is_legitimate_or_true("docker rm -f mycontainer", "Some task")
|
|
|
|
def test_provision_task_is_not_legitimate(self):
|
|
assert not _is_legitimate_or_true("curl -X POST https://api/app || true", "Provision OIDC client")
|
|
|
|
def test_sync_task_name_is_legitimate(self):
|
|
assert _is_legitimate_or_true("psql -c 'ALTER USER' || true", "Sync PostgreSQL password")
|
|
|
|
|
|
class TestCheckTask:
|
|
def test_or_true_on_provision_task_fails(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Provision OIDC client",
|
|
"ansible.builtin.shell",
|
|
"curl -X POST https://zitadel/api || true",
|
|
)
|
|
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
|
|
assert len(violations) >= 1
|
|
assert "|| true" in violations[0]
|
|
|
|
def test_or_true_on_cleanup_task_passes(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Remove old container",
|
|
"ansible.builtin.shell",
|
|
"docker rm -f old-container || true",
|
|
)
|
|
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
|
|
|
def test_failed_when_false_on_provision_fails(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Provision OIDC client",
|
|
"ansible.builtin.shell",
|
|
"curl -X POST https://zitadel/api",
|
|
failed_when=False,
|
|
)
|
|
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
|
|
assert any("failed_when" in v for v in violations)
|
|
|
|
def test_failed_when_false_on_stop_passes(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Stop ZITADEL containers",
|
|
"ansible.builtin.shell",
|
|
"docker stop zitadel",
|
|
failed_when=False,
|
|
)
|
|
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
|
|
|
def test_failed_when_false_on_check_passes(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Check if ZITADEL is running",
|
|
"ansible.builtin.shell",
|
|
"docker inspect zitadel",
|
|
failed_when=False,
|
|
)
|
|
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
|
|
|
def test_allow_marker_in_name_passes(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Provision OIDC #lint:allow-failure-masking",
|
|
"ansible.builtin.shell",
|
|
"curl -X POST https://zitadel/api || true",
|
|
failed_when=False,
|
|
)
|
|
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
|
|
|
def test_safe_task_no_violations(self, tmp_path: Path):
|
|
task = _make_task(
|
|
"Create directory",
|
|
"ansible.builtin.file",
|
|
"path=/opt/app state=directory",
|
|
)
|
|
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
|
|
|
def test_relative_path_outside_repo(self, tmp_path: Path):
|
|
"""Files outside repo_root use the full path in display."""
|
|
task = _make_task(
|
|
"Provision OIDC",
|
|
"ansible.builtin.shell",
|
|
"curl || true",
|
|
)
|
|
other_dir = Path("/tmp/other")
|
|
violations = _check_task(task, other_dir / "test.yml", 1, tmp_path)
|
|
assert len(violations) >= 1
|
|
|
|
|
|
class TestCheckFile:
|
|
def test_clean_file_passes(self, tmp_path: Path):
|
|
p = tmp_path / "test.yml"
|
|
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
|
assert _check_file(p, tmp_path) == []
|
|
|
|
def test_dangerous_pattern_detected(self, tmp_path: Path):
|
|
p = tmp_path / "test.yml"
|
|
p.write_text(
|
|
"- name: Provision OIDC\n"
|
|
" ansible.builtin.shell: |\n"
|
|
" curl -X POST https://api/app || true\n"
|
|
" failed_when: false\n"
|
|
)
|
|
violations = _check_file(p, tmp_path)
|
|
assert len(violations) >= 1
|
|
|
|
def test_file_level_allow_marker_passes(self, tmp_path: Path):
|
|
p = tmp_path / "test.yml"
|
|
p.write_text(
|
|
"# lint:allow-failure-masking\n"
|
|
"- name: Provision OIDC\n"
|
|
" ansible.builtin.shell: |\n"
|
|
" curl -X POST https://api/app || true\n"
|
|
" failed_when: false\n"
|
|
)
|
|
assert _check_file(p, tmp_path) == []
|
|
|
|
def test_nonexistent_file_returns_empty(self):
|
|
assert _check_file(Path("/nonexistent/path/file.yml"), Path.cwd()) == []
|
|
|
|
def test_yaml_parse_error_returns_empty(self, tmp_path: Path):
|
|
p = tmp_path / "test.yml"
|
|
p.write_text("name: Provision OIDC\n shell: curl || true\n: invalid: [")
|
|
assert _check_file(p, tmp_path) == []
|
|
|
|
def test_dict_doc_playbook_with_tasks(self, tmp_path: Path):
|
|
p = tmp_path / "playbook.yml"
|
|
p.write_text(
|
|
"- hosts: all\n"
|
|
" tasks:\n"
|
|
" - name: Provision OIDC\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
)
|
|
violations = _check_file(p, tmp_path)
|
|
assert any("|| true" in v for v in violations)
|
|
|
|
def test_dict_doc_with_pre_tasks_and_post_tasks(self, tmp_path: Path):
|
|
p = tmp_path / "playbook.yml"
|
|
p.write_text(
|
|
"- hosts: all\n"
|
|
" pre_tasks:\n"
|
|
" - name: Provision secret\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
" post_tasks:\n"
|
|
" - name: Provision OIDC\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
" handlers:\n"
|
|
" - name: Provision password\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
)
|
|
violations = _check_file(p, tmp_path)
|
|
assert len(violations) >= 3
|
|
|
|
def test_block_tasks_in_list_item(self, tmp_path: Path):
|
|
p = tmp_path / "tasks.yml"
|
|
p.write_text(
|
|
"- name: Outer task\n"
|
|
" block:\n"
|
|
" - name: Provision OIDC\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
" - name: Provision secret\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
)
|
|
violations = _check_file(p, tmp_path)
|
|
assert any("|| true" in v for v in violations)
|
|
|
|
def test_empty_doc_skipped(self, tmp_path: Path):
|
|
p = tmp_path / "test.yml"
|
|
p.write_text("---\nnull\n---\n- name: Provision OIDC\n ansible.builtin.shell: curl || true\n")
|
|
violations = _check_file(p, tmp_path)
|
|
assert any("|| true" in v for v in violations)
|
|
|
|
def test_pure_dict_doc_with_tasks(self, tmp_path: Path):
|
|
p = tmp_path / "playbook.yml"
|
|
p.write_text(
|
|
"hosts: all\n"
|
|
"tasks:\n"
|
|
" - name: Provision OIDC\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
)
|
|
violations = _check_file(p, tmp_path)
|
|
assert any("|| true" in v for v in violations)
|
|
|
|
|
|
class TestIsLegitimateDevnull:
|
|
def test_cleanup_task_is_legitimate(self):
|
|
assert _is_legitimate_devnull("docker rm old-container 2>/dev/null", "Remove old container")
|
|
|
|
def test_provision_task_is_not_legitimate(self):
|
|
assert not _is_legitimate_devnull("curl -X POST https://api/app 2>/dev/null", "Provision OIDC client")
|
|
|
|
|
|
class TestCheckTasks:
|
|
def test_tasks_section_checked(self, tmp_path: Path):
|
|
doc = {
|
|
"tasks": [
|
|
{"name": "Provision OIDC", "ansible.builtin.shell": "curl || true"},
|
|
],
|
|
}
|
|
errors: list[str] = []
|
|
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
|
assert any("|| true" in e for e in errors)
|
|
|
|
def test_block_inside_tasks_section(self, tmp_path: Path):
|
|
doc = {
|
|
"tasks": [
|
|
{
|
|
"name": "Outer",
|
|
"block": [
|
|
{"name": "Provision secret", "ansible.builtin.shell": "curl || true"},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
errors: list[str] = []
|
|
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
|
assert any("|| true" in e for e in errors)
|
|
|
|
def test_non_list_section_ignored(self, tmp_path: Path):
|
|
doc = {"tasks": "not a list"}
|
|
errors: list[str] = []
|
|
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
|
assert errors == []
|
|
|
|
def test_non_dict_task_ignored(self, tmp_path: Path):
|
|
doc = {"tasks": ["just a string"]}
|
|
errors: list[str] = []
|
|
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
|
assert errors == []
|
|
|
|
|
|
class TestFindTaskFiles:
|
|
def test_single_file(self, tmp_path: Path):
|
|
p = tmp_path / "main.yml"
|
|
p.write_text("- name: test\n")
|
|
assert _find_task_files(p) == [p]
|
|
|
|
def test_single_yaml_file(self, tmp_path: Path):
|
|
p = tmp_path / "main.yaml"
|
|
p.write_text("- name: test\n")
|
|
assert _find_task_files(p) == [p]
|
|
|
|
def test_non_yaml_file_returns_empty(self, tmp_path: Path):
|
|
p = tmp_path / "main.txt"
|
|
p.write_text("hello\n")
|
|
assert _find_task_files(p) == []
|
|
|
|
def test_directory_finds_yaml_files(self, tmp_path: Path):
|
|
(tmp_path / "a.yml").write_text("- name: a\n")
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b.yaml").write_text("- name: b\n")
|
|
(tmp_path / "ignore.txt").write_text("nope\n")
|
|
result = _find_task_files(tmp_path)
|
|
names = {f.name for f in result}
|
|
assert names == {"a.yml", "b.yaml"}
|
|
|
|
def test_directory_skips_molecule(self, tmp_path: Path):
|
|
(tmp_path / "a.yml").write_text("- name: a\n")
|
|
(tmp_path / "molecule").mkdir()
|
|
(tmp_path / "molecule" / "scenario.yml").write_text("- name: mol\n")
|
|
result = _find_task_files(tmp_path)
|
|
assert all("molecule" not in f.parts for f in result)
|
|
|
|
def test_nonexistent_path_returns_empty(self):
|
|
assert _find_task_files(Path("/nonexistent/path/xyz")) == []
|
|
|
|
|
|
class TestMain:
|
|
def test_main_clean_file_exit_zero(self, tmp_path: Path):
|
|
p = tmp_path / "clean.yml"
|
|
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
|
result = CliRunner().invoke(main, ["--path", str(p)])
|
|
assert result.exit_code == 0
|
|
assert "OK" in result.output
|
|
|
|
def test_main_violation_exit_one(self, tmp_path: Path):
|
|
p = tmp_path / "bad.yml"
|
|
p.write_text(
|
|
"- name: Provision OIDC\n"
|
|
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
" failed_when: false\n"
|
|
)
|
|
result = CliRunner().invoke(main, ["--path", str(p)])
|
|
assert result.exit_code == 1
|
|
assert "FAIL" in result.output
|
|
|
|
def test_main_directory(self, tmp_path: Path):
|
|
(tmp_path / "clean.yml").write_text(
|
|
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
|
|
)
|
|
result = CliRunner().invoke(main, ["--path", str(tmp_path)])
|
|
assert result.exit_code == 0
|
|
|
|
def test_main_default_dirs(self, tmp_path: Path, monkeypatch):
|
|
import devx.tools.check_ansible_patterns as mod
|
|
|
|
(tmp_path / "clean.yml").write_text(
|
|
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
|
|
)
|
|
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIRS", [tmp_path])
|
|
result = CliRunner().invoke(main, [])
|
|
assert result.exit_code == 0
|
|
assert "OK" in result.output
|
|
|
|
def test_main_custom_ansible_dirs(self, tmp_path: Path):
|
|
(tmp_path / "bad.yml").write_text(
|
|
"- name: Provision OIDC\n ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
|
)
|
|
result = CliRunner().invoke(main, ["--ansible-dir", str(tmp_path)])
|
|
assert result.exit_code == 1
|