"""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