Files
devx/tests/unit/test_molecule_changed.py
T
kireto ca55cfd29a
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 1m3s
DEVX-177: fix: fail-open molecule selection and honest fast-path contract
2026-09-23 08:10:19 +00:00

227 lines
8.5 KiB
Python

"""Unit tests for devx.molecule.molecule_changed.
Verifies that the script correctly detects changed roles, fails open on
unmapped ansible paths, and only emits roles that exist on disk.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.molecule.molecule_changed import (
detect_changed_roles,
get_changed_files,
main,
role_to_target,
roles_to_targets,
)
TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability")
@pytest.fixture()
def roles_dir(tmp_path: Path) -> Path:
"""Fake roles dir: 4 testable roles (molecule/ present) + 1 untestable."""
for role in TESTABLE_ROLES:
(tmp_path / role / "molecule" / "default").mkdir(parents=True)
(tmp_path / "untested_role").mkdir() # exists but no molecule/ dir
return tmp_path
def test_detect_role_change(roles_dir: Path):
"""A file in ansible/roles/<role>/ maps to that role."""
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
assert roles == {"docker_base"}
def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
"""Changed role absent from roles_dir selects nothing (REQ-2)."""
roles = detect_changed_roles(["ansible/roles/sso_config/tasks/main.yml"], roles_dir)
assert roles == set()
def test_detect_playbook_change(roles_dir: Path):
"""A mapped playbook maps to its included roles, filtered to disk."""
files = ["ansible/playbooks/deploy-observability.yml"]
roles = detect_changed_roles(files, roles_dir)
# zitadel + crowdsec are mapped but absent from the fake roles dir.
assert roles == {"observability", "docker_base"}
def test_detect_shared_infra_triggers_all(roles_dir: Path):
"""ansible.cfg change triggers all testable roles only."""
roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_molecule_shared_path(roles_dir: Path):
"""ansible/molecule/ change triggers all testable roles."""
roles = detect_changed_roles(["ansible/molecule/Dockerfile"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_requirements_yml_triggers_all(roles_dir: Path):
"""ansible/requirements.yml change triggers all testable roles."""
roles = detect_changed_roles(["ansible/requirements.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_detect_group_vars_triggers_all(roles_dir: Path):
"""ansible/group_vars/ change triggers all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/group_vars/all/images.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_unmapped_playbook_fails_open(roles_dir: Path):
"""An unmapped playbook selects all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/playbooks/new-deploy.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_playbook_tasks_dir_fails_open(roles_dir: Path):
"""Shared playbook task files select all testable roles (REQ-1)."""
roles = detect_changed_roles(["ansible/playbooks/_tasks/upgrade-postgres-database.yml"], roles_dir)
assert roles == set(TESTABLE_ROLES)
def test_mapped_playbooks(roles_dir: Path):
"""Each newly mapped playbook selects its roles (REQ-1)."""
expectations = {
"ansible/playbooks/restore.yml": {"restore"},
"ansible/playbooks/upgrade-postgres.yml": {"app_container"},
"ansible/playbooks/rolling-update-gitea.yml": {"app_container"},
"ansible/playbooks/update-alertmanager.yml": {"observability"},
"ansible/playbooks/build-image.yml": {"docker_base"},
"ansible/playbooks/deploy-sso-bridge.yml": set(),
}
for playbook, expected in expectations.items():
assert detect_changed_roles([playbook], roles_dir) == expected, playbook
def test_detect_prepare_vms_playbook(roles_dir: Path):
"""prepare-vms.yml maps to all base roles present on disk."""
roles = detect_changed_roles(["ansible/playbooks/prepare-vms.yml"], roles_dir)
assert roles == {"docker_base"}
def test_detect_deploy_customer_playbook(roles_dir: Path):
"""deploy-customer.yml maps to its roles present on disk."""
roles = detect_changed_roles(["ansible/playbooks/deploy-customer.yml"], roles_dir)
assert roles == {"app_container", "docker_base"}
def test_detect_no_ansible_changes(roles_dir: Path):
"""Non-Ansible files don't trigger any roles."""
roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
assert roles == set()
def test_detect_environments_not_molecule_covered(roles_dir: Path):
"""ansible/environments/ data is not molecule-covered (documented)."""
roles = detect_changed_roles(["ansible/environments/staging/customers.yml"], roles_dir)
assert roles == set()
def test_detect_missing_roles_dir():
"""A nonexistent roles_dir yields no roles (honest: nothing testable)."""
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], "/nonexistent")
assert roles == set()
def test_role_to_target():
"""Role names map to conventional make targets."""
assert role_to_target("docker_base") == "molecule-docker-base"
assert role_to_target("app_hardening") == "molecule-app-hardening"
def test_roles_to_targets():
"""Role names map to make targets."""
targets = roles_to_targets({"docker_base", "zitadel"})
assert targets == ["molecule-docker-base", "molecule-zitadel"]
def test_main_no_changes():
"""When no files changed, outputs message to stderr."""
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
result = CliRunner().invoke(main, ["--print-targets"])
assert result.exit_code == 0
assert "No changed files" in result.output
def test_main_print_targets(roles_dir: Path):
"""--print-targets outputs make targets for existing roles."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/docker_base/tasks/main.yml"],
):
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "molecule-docker-base" in result.output
def test_main_print_roles(roles_dir: Path):
"""--print-roles outputs role names."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/restore/tasks/main.yml"],
):
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "restore" in result.output
def test_main_no_ansible_changes(roles_dir: Path):
"""When only non-Ansible files changed, outputs no scenarios message."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["scripts/molecule_changed.py"],
):
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
assert "No molecule scenarios" in result.output
def test_get_changed_files_with_mock():
"""get_changed_files returns files from git diff."""
with patch("devx.molecule.molecule_changed._run_git", return_value="file1\nfile2\n"):
files = get_changed_files("origin/master")
assert files == ["file1", "file2"]
def test_get_changed_files_falls_back_to_master():
"""When base ref has no diff, falls back to master."""
calls: list[list[str]] = []
def mock_git(args):
calls.append(args)
if "origin/master...HEAD" in args[2]:
return ""
return "ansible/roles/docker_base/tasks/main.yml\n"
with patch("devx.molecule.molecule_changed._run_git", side_effect=mock_git):
files = get_changed_files("origin/master")
assert files == ["ansible/roles/docker_base/tasks/main.yml"]
assert len(calls) == 2
def test_get_changed_files_empty():
"""When no changes in either ref, returns empty list."""
with patch("devx.molecule.molecule_changed._run_git", return_value=""):
files = get_changed_files("origin/master")
assert files == []
def test_main_default_base(roles_dir: Path):
"""main() with no --base uses origin/master."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/restore/tasks/main.yml"],
) as mock:
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
assert result.exit_code == 0
mock.assert_called_once_with("origin/master")