Files
claude-plugin/plugins/reviews/skills/audit-terraform/tests/test_cli.py
T
mroberts 37fc3fb291 Fix audit tool bootstrap and add per-run preflight
audit-code install-tools.sh:
- Buffer the opengrep release JSON before grep -m1; curl died with (23)
  under pipefail when grep quit early.
- Use ${m}: in the PowerShell block; $m: parsed as a scope-qualified var.
- On Arch, skip paru/yay when pacman -Q shows every package installed,
  since --needed still invokes sudo.
- Add --check-only (fast, installs nothing, non-zero naming missing tools)
  and --user-only (no system package managers, no sudo).

log-run.py (both skills): put the skill dir on sys.path so running it as
a script from any cwd no longer raises ModuleNotFoundError.

audit-terraform: move deps from requirements.txt into pyproject
dependency groups and add scripts/install-tools.sh (uv sync --group tools,
then check trivy, tflint, tofu, terragrunt, gh).

Both SKILL.md files gain a 0.5 Preflight step and call scripts through
uv run --project ${SKILL_DIR}. tools_unavailable is now a map of tool to
exact install command; audit-terraform skips trivy when absent and stops
with an install hint instead of crashing when tofu/terragrunt is missing.
2026-09-22 15:21:28 -05:00

467 lines
17 KiB
Python

import importlib.util
import json
import shutil
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
_SKILL_ROOT = Path(__file__).resolve().parent.parent
def _load_cli():
spec = importlib.util.spec_from_file_location(
"collect_changes", _SKILL_ROOT / "scripts" / "collect-changes.py"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _stage_fixture(dest: Path) -> Path:
src = _SKILL_ROOT / "tests" / "fixtures" / "tofu-sample"
shutil.copytree(src, dest)
return dest
def _fake_subprocess(cmd, **kwargs):
if cmd[:2] == ["git", "-C"]:
sub = cmd[2:]
else:
sub = cmd
if "symbolic-ref" in sub:
return MagicMock(returncode=0, stdout="refs/remotes/origin/main\n", stderr="")
if "fetch" in sub:
return MagicMock(returncode=0, stdout="", stderr="")
if "rev-parse" in sub and "--verify" in sub:
return MagicMock(returncode=0, stdout="abc123\n", stderr="")
if cmd[:1] == ["git"] and "diff" in cmd:
diff = (
"diff --git a/main.tf b/main.tf\n"
"index 1..2 100644\n"
"--- a/main.tf\n"
"+++ b/main.tf\n"
"@@ -20,0 +21,1 @@\n"
"+ bucket_prefix = \"x\"\n"
"diff --git a/modules/widget/main.tf b/modules/widget/main.tf\n"
"index 3..4 100644\n"
"--- a/modules/widget/main.tf\n"
"+++ b/modules/widget/main.tf\n"
"@@ -1,0 +2,1 @@\n"
"+ # touched\n"
)
return MagicMock(returncode=0, stdout=diff, stderr="")
if cmd[0] == "tofu" and "init" in cmd:
return MagicMock(returncode=0, stdout="initialized\n", stderr="")
if cmd[0] == "tofu" and "plan" in cmd:
plan = (
"OpenTofu will perform the following actions:\n\n"
" # null_resource.top will be updated in-place\n"
" ~ resource \"null_resource\" \"top\" {}\n\n"
" # module.widget_a.null_resource.thing will be updated in-place\n"
" ~ resource \"null_resource\" \"thing\" {}\n\n"
" # module.widget_b.null_resource.thing will be updated in-place\n"
" ~ resource \"null_resource\" \"thing\" {}\n\n"
"Plan: 0 to add, 3 to change, 0 to destroy.\n"
)
return MagicMock(returncode=0, stdout=plan, stderr="")
if cmd[:3] == ["trivy", "config", "--quiet"]:
trivy = {
"SchemaVersion": 2,
"Results": [
{
"Target": "main.tf",
"Class": "config",
"Type": "terraform",
"Misconfigurations": [
{
"ID": "AVD-AWS-0089",
"AVDID": "AVD-AWS-0089",
"Title": "S3 bucket allows public ACL",
"Description": "Buckets should not allow public ACLs.",
"Message": "Bucket ACL allows public access.",
"Severity": "HIGH",
"CauseMetadata": {
"Resource": "aws_s3_bucket.audit_logs",
"StartLine": 21,
"EndLine": 30,
},
}
],
}
],
}
return MagicMock(returncode=0, stdout=json.dumps(trivy), stderr="")
return MagicMock(returncode=0, stdout="", stderr="")
def test_cli_happy_path(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
with patch("subprocess.run", side_effect=_fake_subprocess):
rc = mod.main([
"--repo", str(repo),
"--base", "main",
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 0, (out_dir / "manifest.json").read_text()
manifest = json.loads((out_dir / "manifest.json").read_text())
assert manifest["mode"] == "local"
assert manifest["base_ref"] == "main"
assert set(manifest["changed_source_dirs"]) == {".", "modules/widget"}
plan_dirs = {pu["plan_dir"] for pu in manifest["plan_units"]}
assert plan_dirs == {"."}
pu = manifest["plan_units"][0]
assert pu["tool"] == "tofu"
assert pu["plan"]["summary"] == "0 to add, 3 to change, 0 to destroy"
catalog = manifest["catalog"]
keys = {(e["source_dir"], e["local_address"]) for e in catalog}
assert (".", "null_resource.top") in keys
assert ("modules/widget", "null_resource.thing") in keys
module_entries = [e for e in catalog if e["source_dir"] == "modules/widget"]
for e in module_entries:
plan_dirs = {i["plan_dir"] for i in e["instances"]}
assert plan_dirs == {"."}, e
addrs = {i["address_at_plan"] for i in e["instances"]}
assert any(a.startswith("module.widget_a") for a in addrs)
assert any(a.startswith("module.widget_b") for a in addrs)
for e in module_entries:
assert e["block_header"], f"missing block_header on {e}"
assert e["evidence_line"], f"missing evidence_line on {e}"
assert isinstance(e["key_attributes"], dict)
assert "review_context" in e
assert set(e["review_context"]) == {"variables", "locals", "related_blocks"}
assert e["block_file"].endswith(".tf")
assert e["block_start"] >= 1
assert "block_text" not in e
assert (out_dir / "trivy-findings.json").is_file()
assert manifest["trivy_findings"] == [
{
"check_id": "AVD-AWS-0089",
"title": "S3 bucket allows public ACL",
"severity": "high",
"message": "Bucket ACL allows public access.",
"file": "main.tf",
"start_line": 21,
"end_line": 30,
"resource_type": "aws_s3_bucket",
"source": "trivy",
}
]
refs = json.loads((out_dir / "reference_sets.json").read_text())
assert "." in refs or "modules/widget" in refs
def test_cli_writes_per_agent_slices(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
with patch("subprocess.run", side_effect=_fake_subprocess):
rc = mod.main([
"--repo", str(repo), "--base", "main", "--head", "HEAD",
"--output-dir", str(out_dir), "--mode", "local",
])
assert rc == 0
full = json.loads((out_dir / "manifest.json").read_text())
for agent in ("fsbp", "cis", "aws-bp", "consistency"):
sliced = json.loads((out_dir / f"manifest-{agent}.json").read_text())
assert sliced["base_ref"] == full["base_ref"]
if agent in ("fsbp", "cis", "aws-bp"):
for entry in sliced["catalog"]:
assert entry["type"].startswith("aws_"), (
f"non-aws type leaked into {agent}: {entry['type']}"
)
assert sliced["catalog"] == []
assert sliced["trivy_findings"] == full["trivy_findings"]
if agent == "consistency":
assert sliced["changed_source_dirs"] == full["changed_source_dirs"]
assert len(sliced["catalog"]) == len(full["catalog"])
assert sliced["trivy_findings"] == full["trivy_findings"]
def test_cli_records_missing_scanners_with_install_command(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
ran: list[str] = []
def _fake(cmd, **kw):
ran.append(cmd[0])
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake), \
patch("shutil.which", side_effect=lambda t: None if t in ("trivy", "tflint") else f"/usr/bin/{t}"):
rc = mod.main([
"--repo", str(repo), "--base", "main", "--head", "HEAD",
"--output-dir", str(out_dir), "--mode", "local",
])
assert rc == 0
manifest = json.loads((out_dir / "manifest.json").read_text())
assert manifest["tools_unavailable"] == {
"trivy": "go install github.com/aquasecurity/trivy/cmd/trivy@latest",
"tflint": "go install github.com/terraform-linters/tflint@latest",
}
assert "trivy" not in ran
assert manifest["trivy_findings"] == []
def test_cli_stops_with_install_command_when_plan_tool_missing(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
with patch("subprocess.run", side_effect=_fake_subprocess), \
patch("shutil.which", side_effect=lambda t: None if t == "tofu" else f"/usr/bin/{t}"):
rc = mod.main([
"--repo", str(repo), "--base", "main", "--head", "HEAD",
"--output-dir", str(out_dir), "--mode", "local",
])
assert rc == 1
manifest = json.loads((out_dir / "manifest.json").read_text())
install = "go install github.com/opentofu/opentofu/cmd/tofu@latest"
assert manifest["tools_unavailable"] == {"tofu": install}
assert manifest["errors"] == [
f"tofu is not installed, so the changed units cannot be planned. Install it with `{install}` and re-run."
]
def test_cli_preserves_terragrunt_only_change_context(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
terragrunt_dir = repo / "live" / "prod" / "app"
terragrunt_dir.mkdir(parents=True)
(terragrunt_dir / "terragrunt.hcl").write_text("inputs = { instance_count = 2 }\n")
out_dir = tmp_path / "out"
mod = _load_cli()
def _fake(cmd, **kw):
if cmd[:1] == ["git"] and "diff" in cmd:
diff = (
"diff --git a/live/prod/app/terragrunt.hcl b/live/prod/app/terragrunt.hcl\n"
"index 1..2 100644\n"
"--- a/live/prod/app/terragrunt.hcl\n"
"+++ b/live/prod/app/terragrunt.hcl\n"
"@@ -1 +1 @@\n"
"-inputs = { instance_count = 1 }\n"
"+inputs = { instance_count = 2 }\n"
)
return MagicMock(returncode=0, stdout=diff, stderr="")
if cmd[0] == "terragrunt" and "init" in cmd:
return MagicMock(returncode=0, stdout="initialized\n", stderr="")
if cmd[0] == "terragrunt" and "plan" in cmd:
plan = (
"OpenTofu will perform the following actions:\n\n"
"Plan: 0 to add, 0 to change, 0 to destroy.\n"
)
return MagicMock(returncode=0, stdout=plan, stderr="")
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake):
rc = mod.main([
"--repo", str(repo),
"--base", "main",
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 0, (out_dir / "manifest.json").read_text()
manifest = json.loads((out_dir / "manifest.json").read_text())
assert "live/prod/app" in manifest["changed_source_dirs"]
assert manifest["catalog"] == []
assert manifest["plan_units"] == [{
"plan_dir": "live/prod/app",
"tool": "terragrunt",
"init": {"ok": True, "stdout_tail": "initialized\n", "stderr_tail": ""},
"plan": {
"ok": True,
"stdout_path": str(out_dir / "plans" / "live_prod_app.txt"),
"exit_code": 0,
"summary": "0 to add, 0 to change, 0 to destroy",
},
"triggered_by": ["live/prod/app"],
"terragrunt_changed": True,
"changed_files": ["live/prod/app/terragrunt.hcl"],
}]
fsbp = json.loads((out_dir / "manifest-fsbp.json").read_text())
assert fsbp["plan_units"] == [{
"plan_dir": "live/prod/app",
"tool": "terragrunt",
"plan_ok": True,
"summary": "0 to add, 0 to change, 0 to destroy",
"terragrunt_changed": True,
"changed_files": ["live/prod/app/terragrunt.hcl"],
}]
consistency = json.loads((out_dir / "manifest-consistency.json").read_text())
assert consistency["plan_units"] == manifest["plan_units"]
def test_cli_aborts_when_plan_fails(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
def _fake(cmd, **kw):
if cmd[0] == "tofu" and "plan" in cmd:
return MagicMock(
returncode=1,
stdout="Error: syntax error in main.tf\n",
stderr="",
)
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake):
rc = mod.main([
"--repo", str(repo),
"--base", "main",
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 1
manifest = json.loads((out_dir / "manifest.json").read_text())
assert any("syntax error" in e for e in manifest["errors"])
def test_cli_records_git_diff_failure_in_manifest(tmp_path):
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
def _fake(cmd, **kw):
if cmd[:1] == ["git"] and "diff" in cmd:
return MagicMock(returncode=128, stdout="",
stderr="fatal: bad revision 'main...HEAD'\n")
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake):
rc = mod.main([
"--repo", str(repo),
"--base", "main",
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 1
manifest = json.loads((out_dir / "manifest.json").read_text())
assert any("bad revision" in e for e in manifest["errors"])
def test_cli_auto_resolves_base_to_origin(tmp_path):
"""With no --base, the CLI fetches and diffs against origin/<default>."""
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
captured_diff_base: list[str] = []
def _fake(cmd, **kw):
if cmd[:1] == ["git"] and "diff" in cmd:
for arg in cmd:
if "..." in arg:
captured_diff_base.append(arg)
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake):
rc = mod.main([
"--repo", str(repo),
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 0
assert captured_diff_base, "git diff was never invoked"
assert captured_diff_base[0].startswith("origin/main..."), (
f"diff base should be origin/<default>, got: {captured_diff_base[0]}"
)
manifest = json.loads((out_dir / "manifest.json").read_text())
assert manifest["base_ref"] == "origin/main"
def test_cli_falls_back_to_local_branch_when_origin_missing(tmp_path):
"""If origin/<default> doesn't exist (no remote, fetch fails), fall back."""
repo = _stage_fixture(tmp_path / "repo")
out_dir = tmp_path / "out"
mod = _load_cli()
def _fake(cmd, **kw):
if cmd[:2] == ["git", "-C"]:
sub = cmd[2:]
else:
sub = cmd
if "rev-parse" in sub and "--verify" in sub:
return MagicMock(returncode=1, stdout="", stderr="fatal: unknown revision\n")
return _fake_subprocess(cmd, **kw)
with patch("subprocess.run", side_effect=_fake):
rc = mod.main([
"--repo", str(repo),
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
assert rc == 0
manifest = json.loads((out_dir / "manifest.json").read_text())
assert manifest["base_ref"] == "main"
def _has_tofu() -> bool:
from shutil import which
return which("tofu") is not None
@pytest.mark.integration
@pytest.mark.skipif(not _has_tofu(), reason="tofu not installed")
def test_cli_real_tofu_plan(tmp_path, monkeypatch):
repo = _stage_fixture(tmp_path / "repo")
subprocess.run(["git", "init", "-q", str(repo)], check=True)
subprocess.run(["git", "-C", str(repo), "checkout", "-qb", "main"], check=True)
subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
subprocess.run(
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-qm", "init"], check=True,
)
subprocess.run(["git", "-C", str(repo), "checkout", "-qb", "feature"], check=True)
main_tf = repo / "main.tf"
main_tf.write_text(main_tf.read_text() + "\n# touched\n")
subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
subprocess.run(
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-qm", "touch"], check=True,
)
out_dir = tmp_path / "out"
plugin_cache = tmp_path / "plugin-cache"
plugin_cache.mkdir()
monkeypatch.setenv("TF_PLUGIN_CACHE_DIR", str(plugin_cache))
monkeypatch.setenv("TF_IN_AUTOMATION", "1")
mod = _load_cli()
rc = mod.main([
"--repo", str(repo),
"--base", "main",
"--head", "HEAD",
"--output-dir", str(out_dir),
"--mode", "local",
])
manifest = json.loads((out_dir / "manifest.json").read_text())
assert manifest["plan_units"], (
f"no plan unit recorded; errors={manifest.get('errors')}"
)
pu = manifest["plan_units"][0]
assert pu["tool"] == "tofu"
assert pu["init"]["ok"] is True, pu["init"]
assert pu["plan"]["ok"] is True, pu["plan"]
assert rc == 0