1373 lines
39 KiB
Python
1373 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from enroll.diff import (
|
|
_Spinner,
|
|
_enforcement_plan,
|
|
has_enforceable_drift,
|
|
_role_tag,
|
|
_utc_now_iso,
|
|
_report_markdown,
|
|
)
|
|
|
|
|
|
def _make_bundle_dir(tmp_path: Path) -> Path:
|
|
b = tmp_path / "bundle"
|
|
(b / "artifacts").mkdir(parents=True)
|
|
(b / "state.json").write_text("{}\n", encoding="utf-8")
|
|
return b
|
|
|
|
|
|
def _tar_gz_of_dir(src: Path, out: Path) -> None:
|
|
with tarfile.open(out, mode="w:gz") as tf:
|
|
# tar -C src . semantics
|
|
for p in src.rglob("*"):
|
|
rel = p.relative_to(src)
|
|
tf.add(p, arcname=str(rel))
|
|
|
|
|
|
def test_bundle_from_directory_and_statejson_path(tmp_path: Path):
|
|
import enroll.diff as d
|
|
|
|
b = _make_bundle_dir(tmp_path)
|
|
|
|
br1 = d._bundle_from_input(str(b), sops_mode=False)
|
|
assert br1.dir == b
|
|
assert br1.state_path.exists()
|
|
|
|
br2 = d._bundle_from_input(str(b / "state.json"), sops_mode=False)
|
|
assert br2.dir == b
|
|
|
|
|
|
def test_bundle_from_tarball_extracts(tmp_path: Path):
|
|
import enroll.diff as d
|
|
|
|
b = _make_bundle_dir(tmp_path)
|
|
tgz = tmp_path / "bundle.tgz"
|
|
_tar_gz_of_dir(b, tgz)
|
|
|
|
br = d._bundle_from_input(str(tgz), sops_mode=False)
|
|
try:
|
|
assert br.dir.is_dir()
|
|
assert (br.dir / "state.json").exists()
|
|
finally:
|
|
if br.tempdir:
|
|
br.tempdir.cleanup()
|
|
|
|
|
|
def test_bundle_from_sops_like_file(monkeypatch, tmp_path: Path):
|
|
import enroll.diff as d
|
|
|
|
b = _make_bundle_dir(tmp_path)
|
|
tgz = tmp_path / "bundle.tar.gz"
|
|
_tar_gz_of_dir(b, tgz)
|
|
|
|
# Pretend the tarball is an encrypted bundle by giving it a .sops name.
|
|
sops_path = tmp_path / "bundle.tar.gz.sops"
|
|
sops_path.write_bytes(tgz.read_bytes())
|
|
|
|
# Stub out sops machinery: "decrypt" just copies through.
|
|
monkeypatch.setattr(d, "require_sops_cmd", lambda: "sops")
|
|
|
|
def fake_decrypt(src: Path, dest: Path, mode: int):
|
|
dest.write_bytes(Path(src).read_bytes())
|
|
try:
|
|
os.chmod(dest, mode)
|
|
except OSError:
|
|
pass
|
|
|
|
monkeypatch.setattr(d, "decrypt_file_binary_to", fake_decrypt)
|
|
|
|
br = d._bundle_from_input(str(sops_path), sops_mode=False)
|
|
try:
|
|
assert (br.dir / "state.json").exists()
|
|
finally:
|
|
if br.tempdir:
|
|
br.tempdir.cleanup()
|
|
|
|
|
|
def test_bundle_from_input_missing_path(tmp_path: Path):
|
|
import enroll.diff as d
|
|
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
d._bundle_from_input(str(tmp_path / "nope"), sops_mode=False)
|
|
|
|
|
|
import json
|
|
import sys
|
|
|
|
|
|
from enroll.diff import (
|
|
_bundle_from_input,
|
|
_file_index,
|
|
_iter_managed_files,
|
|
_load_state,
|
|
_pkg_version_display,
|
|
_pkg_version_key,
|
|
_progress_enabled,
|
|
_roles,
|
|
_service_units,
|
|
_sha256,
|
|
_users_by_name,
|
|
compare_harvests,
|
|
)
|
|
from enroll.sopsutil import SopsError
|
|
|
|
|
|
def test_progress_enabled_when_tty(monkeypatch):
|
|
monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
|
|
monkeypatch.delenv("ENROLL_NO_PROGRESS", raising=False)
|
|
assert _progress_enabled() is True
|
|
|
|
|
|
def test_progress_enabled_when_not_tty(monkeypatch):
|
|
monkeypatch.setattr(sys.stderr, "isatty", lambda: False)
|
|
monkeypatch.delenv("ENROLL_NO_PROGRESS", raising=False)
|
|
assert _progress_enabled() is False
|
|
|
|
|
|
def test_progress_enabled_with_env_var(monkeypatch):
|
|
monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "1")
|
|
assert _progress_enabled() is False
|
|
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "true")
|
|
assert _progress_enabled() is False
|
|
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "yes")
|
|
assert _progress_enabled() is False
|
|
|
|
|
|
def test_sha256(tmp_path: Path):
|
|
test_file = tmp_path / "test.txt"
|
|
test_file.write_text("hello world", encoding="utf-8")
|
|
hash_result = _sha256(test_file)
|
|
assert len(hash_result) == 64
|
|
|
|
|
|
def test_sha256_empty_file(tmp_path: Path):
|
|
test_file = tmp_path / "empty.txt"
|
|
test_file.write_bytes(b"")
|
|
hash_result = _sha256(test_file)
|
|
assert (
|
|
hash_result
|
|
== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
)
|
|
|
|
|
|
def test_bundle_from_input_directory(tmp_path: Path):
|
|
result = _bundle_from_input(str(tmp_path), sops_mode=False)
|
|
assert result.dir == tmp_path
|
|
assert result.tempdir is None
|
|
|
|
|
|
def test_bundle_from_input_state_json_path(tmp_path: Path):
|
|
state_file = tmp_path / "state.json"
|
|
state_file.write_text("{}", encoding="utf-8")
|
|
result = _bundle_from_input(str(state_file), sops_mode=False)
|
|
assert result.dir == tmp_path
|
|
assert result.tempdir is None
|
|
|
|
|
|
def test_bundle_from_input_not_found():
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
_bundle_from_input("/nonexistent/path", sops_mode=False)
|
|
assert "not found" in str(exc_info.value).lower()
|
|
|
|
|
|
def test_bundle_from_input_tarball(tmp_path: Path):
|
|
bundle_dir = tmp_path / "bundle"
|
|
bundle_dir.mkdir()
|
|
state_file = bundle_dir / "state.json"
|
|
state_file.write_text("{}", encoding="utf-8")
|
|
|
|
tar_path = tmp_path / "bundle.tar.gz"
|
|
with tarfile.open(tar_path, "w:gz") as tf:
|
|
tf.add(bundle_dir, arcname="bundle")
|
|
|
|
result = _bundle_from_input(str(tar_path), sops_mode=False)
|
|
assert result.dir.exists()
|
|
assert result.tempdir is not None
|
|
result.tempdir.cleanup()
|
|
|
|
|
|
def test_bundle_from_input_invalid_type(tmp_path: Path):
|
|
test_file = tmp_path / "test.txt"
|
|
test_file.write_text("not a bundle", encoding="utf-8")
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
_bundle_from_input(str(test_file), sops_mode=False)
|
|
assert "not a directory" in str(exc_info.value).lower()
|
|
|
|
|
|
def test_load_state(tmp_path: Path):
|
|
state_file = tmp_path / "state.json"
|
|
state_file.write_text('{"host": {"hostname": "test"}}', encoding="utf-8")
|
|
result = _load_state(tmp_path)
|
|
assert result["host"]["hostname"] == "test"
|
|
|
|
|
|
def test_roles_with_roles():
|
|
state = {"roles": {"users": {}, "services": []}}
|
|
result = _roles(state)
|
|
assert "users" in result
|
|
|
|
|
|
def test_service_units_empty():
|
|
assert _service_units({}) == {}
|
|
|
|
|
|
def test_service_units_with_services():
|
|
state = {
|
|
"roles": {
|
|
"services": [
|
|
{"unit": "nginx.service", "active_state": "active"},
|
|
{"unit": "ssh.service", "active_state": "inactive"},
|
|
]
|
|
}
|
|
}
|
|
result = _service_units(state)
|
|
assert "nginx.service" in result
|
|
assert "ssh.service" in result
|
|
assert result["nginx.service"]["active_state"] == "active"
|
|
|
|
|
|
def test_users_by_name_empty():
|
|
assert _users_by_name({}) == {}
|
|
|
|
|
|
def test_users_by_name_with_users():
|
|
state = {
|
|
"roles": {
|
|
"users": {
|
|
"users": [
|
|
{"name": "alice", "uid": 1000},
|
|
{"name": "bob", "uid": 1001},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
result = _users_by_name(state)
|
|
assert "alice" in result
|
|
assert "bob" in result
|
|
assert result["alice"]["uid"] == 1000
|
|
|
|
|
|
def test_pkg_version_key_with_version():
|
|
entry = {"version": "1.2.3"}
|
|
assert _pkg_version_key(entry) == "1.2.3"
|
|
|
|
|
|
def test_pkg_version_key_with_installations():
|
|
entry = {
|
|
"installations": [
|
|
{"arch": "x86_64", "version": "1.2.3"},
|
|
{"arch": "aarch64", "version": "1.2.3"},
|
|
]
|
|
}
|
|
result = _pkg_version_key(entry)
|
|
assert "x86_64:1.2.3" in result
|
|
assert "aarch64:1.2.3" in result
|
|
|
|
|
|
def test_pkg_version_key_with_empty_version():
|
|
entry = {"version": None}
|
|
assert _pkg_version_key(entry) is None
|
|
|
|
|
|
def test_pkg_version_key_with_invalid_installations():
|
|
entry = {"installations": ["not_a_dict", {"arch": "x86_64", "version": "1.0"}]}
|
|
result = _pkg_version_key(entry)
|
|
assert "x86_64:1.0" in result
|
|
|
|
|
|
def test_pkg_version_display_with_version():
|
|
entry = {"version": "1.2.3"}
|
|
assert _pkg_version_display(entry) == "1.2.3"
|
|
|
|
|
|
def test_pkg_version_display_with_installations():
|
|
entry = {
|
|
"installations": [
|
|
{"arch": "x86_64", "version": "1.2.3"},
|
|
]
|
|
}
|
|
assert _pkg_version_display(entry) == "1.2.3 (x86_64)"
|
|
|
|
|
|
def test_pkg_version_display_empty():
|
|
assert _pkg_version_display({}) is None
|
|
|
|
|
|
def test_iter_managed_files_empty():
|
|
state = {"roles": {}}
|
|
files = list(_iter_managed_files(state))
|
|
assert files == []
|
|
|
|
|
|
def test_iter_managed_files_services():
|
|
state = {
|
|
"roles": {
|
|
"services": [
|
|
{
|
|
"role_name": "nginx",
|
|
"managed_files": [
|
|
{"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0] == (
|
|
"nginx",
|
|
{"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"},
|
|
)
|
|
|
|
|
|
def test_iter_managed_files_packages():
|
|
state = {
|
|
"roles": {
|
|
"packages": [
|
|
{
|
|
"role_name": "vim",
|
|
"managed_files": [{"path": "/usr/bin/vim", "src_rel": "bin/vim"}],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "vim"
|
|
|
|
|
|
def test_iter_managed_files_users():
|
|
state = {
|
|
"roles": {
|
|
"users": {
|
|
"role_name": "users",
|
|
"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}],
|
|
}
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "users"
|
|
|
|
|
|
def test_iter_managed_files_apt_config():
|
|
state = {
|
|
"roles": {
|
|
"apt_config": {
|
|
"role_name": "apt_config",
|
|
"managed_files": [
|
|
{"path": "/etc/apt/sources.list", "src_rel": "sources.list"}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "apt_config"
|
|
|
|
|
|
def test_iter_managed_files_etc_custom():
|
|
state = {
|
|
"roles": {
|
|
"etc_custom": {
|
|
"role_name": "etc_custom",
|
|
"managed_files": [
|
|
{"path": "/etc/custom.conf", "src_rel": "custom.conf"}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "etc_custom"
|
|
|
|
|
|
def test_iter_managed_files_usr_local_custom():
|
|
state = {
|
|
"roles": {
|
|
"usr_local_custom": {
|
|
"role_name": "usr_local_custom",
|
|
"managed_files": [
|
|
{"path": "/usr/local/bin/script", "src_rel": "bin/script"}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "usr_local_custom"
|
|
|
|
|
|
def test_iter_managed_files_extra_paths():
|
|
state = {
|
|
"roles": {
|
|
"extra_paths": {
|
|
"role_name": "extra_paths",
|
|
"managed_files": [{"path": "/opt/app/config", "src_rel": "config"}],
|
|
}
|
|
}
|
|
}
|
|
files = list(_iter_managed_files(state))
|
|
assert len(files) == 1
|
|
assert files[0][0] == "extra_paths"
|
|
|
|
|
|
def test_file_index_empty():
|
|
state = {"roles": {}}
|
|
index = _file_index(Path("/tmp"), state)
|
|
assert index == {}
|
|
|
|
|
|
def test_file_index_with_files(tmp_path: Path):
|
|
state = {
|
|
"roles": {
|
|
"users": {
|
|
"managed_files": [
|
|
{"path": "/etc/passwd", "src_rel": "passwd", "owner": "root"},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
index = _file_index(tmp_path, state)
|
|
assert "/etc/passwd" in index
|
|
assert index["/etc/passwd"].role == "users"
|
|
assert index["/etc/passwd"].owner == "root"
|
|
|
|
|
|
def test_file_index_duplicates_first_wins(tmp_path: Path):
|
|
state = {
|
|
"roles": {
|
|
"users": {
|
|
"managed_files": [
|
|
{"path": "/etc/passwd", "src_rel": "passwd"},
|
|
]
|
|
},
|
|
"etc_custom": {
|
|
"managed_files": [
|
|
{"path": "/etc/passwd", "src_rel": "custom_passwd"},
|
|
]
|
|
},
|
|
}
|
|
}
|
|
index = _file_index(tmp_path, state)
|
|
assert "/etc/passwd" in index
|
|
assert index["/etc/passwd"].src_rel == "passwd"
|
|
|
|
|
|
def test_file_index_skips_missing_path_or_src_rel(tmp_path: Path):
|
|
state = {
|
|
"roles": {
|
|
"users": {
|
|
"managed_files": [
|
|
{"path": "/etc/passwd"}, # missing src_rel
|
|
{"src_rel": "passwd"}, # missing path
|
|
]
|
|
}
|
|
}
|
|
}
|
|
index = _file_index(tmp_path, state)
|
|
assert index == {}
|
|
|
|
|
|
def test_compare_harvests_no_changes(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {"vim": {"version": "1.0"}}},
|
|
"roles": {},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {"vim": {"version": "1.0"}}},
|
|
"roles": {},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is False
|
|
assert report["packages"]["added"] == []
|
|
assert report["packages"]["removed"] == []
|
|
|
|
|
|
def test_compare_harvests_package_added(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps({"inventory": {"packages": {}}, "roles": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is True
|
|
assert "vim" in report["packages"]["added"]
|
|
|
|
|
|
def test_compare_harvests_package_removed(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps({"inventory": {"packages": {}}, "roles": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is True
|
|
assert "vim" in report["packages"]["removed"]
|
|
|
|
|
|
def test_compare_harvests_package_version_changed(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "2.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is True
|
|
assert len(report["packages"]["version_changed"]) == 1
|
|
|
|
|
|
def test_compare_harvests_ignore_package_versions(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{"inventory": {"packages": {"vim": {"version": "2.0"}}}, "roles": {}}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(
|
|
str(old_bundle), str(new_bundle), ignore_package_versions=True
|
|
)
|
|
assert report["packages"]["version_changed_ignored_count"] == 1
|
|
|
|
|
|
def test_compare_harvests_service_added(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps({"inventory": {"packages": {}}, "roles": {"services": []}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {}},
|
|
"roles": {"services": [{"unit": "nginx.service"}]},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is True
|
|
assert "nginx.service" in report["services"]["enabled_added"]
|
|
|
|
|
|
def test_compare_harvests_user_added(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps({"inventory": {"packages": {}}, "roles": {"users": {"users": []}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {}},
|
|
"roles": {"users": {"users": [{"name": "alice", "uid": 1000}]}},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
|
|
assert has_changes is True
|
|
assert "alice" in report["users"]["added"]
|
|
|
|
|
|
def test_compare_harvests_with_exclude_paths(tmp_path: Path):
|
|
old_bundle = tmp_path / "old"
|
|
old_bundle.mkdir()
|
|
old_artifacts = old_bundle / "artifacts" / "users"
|
|
old_artifacts.mkdir(parents=True)
|
|
(old_artifacts / "passwd").write_text("old", encoding="utf-8")
|
|
(old_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {}},
|
|
"roles": {
|
|
"users": {
|
|
"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]
|
|
}
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
new_bundle = tmp_path / "new"
|
|
new_bundle.mkdir()
|
|
new_artifacts = new_bundle / "artifacts" / "users"
|
|
new_artifacts.mkdir(parents=True)
|
|
(new_artifacts / "passwd").write_text("new", encoding="utf-8")
|
|
(new_bundle / "state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"inventory": {"packages": {}},
|
|
"roles": {
|
|
"users": {
|
|
"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]
|
|
}
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report, has_changes = compare_harvests(
|
|
str(old_bundle), str(new_bundle), exclude_paths=["/etc/passwd"]
|
|
)
|
|
assert "/etc/passwd" not in [f["path"] for f in report["files"]["added"]]
|
|
assert "/etc/passwd" not in [f["path"] for f in report["files"]["removed"]]
|
|
assert "/etc/passwd" not in [f["path"] for f in report["files"]["changed"]]
|
|
|
|
|
|
def test_utc_now_iso():
|
|
result = _utc_now_iso()
|
|
assert "T" in result
|
|
assert "+" in result or "Z" in result
|
|
|
|
|
|
def test_spinner_stop_without_start():
|
|
spinner = _Spinner("Test")
|
|
spinner.stop(final_line="Done")
|
|
# Should not raise
|
|
|
|
|
|
def test_spinner_run_exception(monkeypatch):
|
|
class FakeStderr:
|
|
def write(self, s):
|
|
raise Exception("Write error")
|
|
|
|
def flush(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(sys, "stderr", FakeStderr())
|
|
|
|
spinner = _Spinner("Test")
|
|
spinner.start()
|
|
spinner.stop()
|
|
|
|
|
|
def test_spinner_double_start():
|
|
spinner = _Spinner("Test")
|
|
spinner.start()
|
|
spinner.start() # Should not raise or spawn another thread
|
|
spinner.stop()
|
|
|
|
|
|
def test_role_tag_normal():
|
|
assert _role_tag("nginx") == "role_nginx"
|
|
assert _role_tag("my-app") == "role_my-app"
|
|
|
|
|
|
def test_role_tag_with_special_chars():
|
|
assert _role_tag("my.app") == "role_my_app"
|
|
assert _role_tag("my app") == "role_my_app"
|
|
|
|
|
|
def test_role_tag_empty():
|
|
assert _role_tag("") == "role_other"
|
|
assert _role_tag(" ") == "role_other"
|
|
|
|
|
|
def test_has_enforceable_drift_packages_removed():
|
|
report = {"packages": {"removed": ["vim"]}}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_services_removed():
|
|
report = {"services": {"enabled_removed": ["nginx.service"]}}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_service_changed():
|
|
report = {
|
|
"services": {
|
|
"changed": [
|
|
{
|
|
"unit": "nginx.service",
|
|
"changes": {"active_state": {"old": "active", "new": "inactive"}},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_service_package_only_changed():
|
|
# Service changed only in packages - should NOT be enforceable
|
|
report = {
|
|
"services": {
|
|
"changed": [
|
|
{
|
|
"unit": "nginx.service",
|
|
"changes": {"packages": {"added": ["nginx-extra"]}},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
assert has_enforceable_drift(report) is False
|
|
|
|
|
|
def test_has_enforceable_drift_users_removed():
|
|
report = {"users": {"removed": ["alice"]}}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_users_changed():
|
|
report = {
|
|
"users": {
|
|
"changed": [
|
|
{"name": "alice", "changes": {"uid": {"old": 1000, "new": 1001}}}
|
|
]
|
|
}
|
|
}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_files_removed():
|
|
report = {
|
|
"files": {
|
|
"removed": [{"path": "/etc/passwd", "role": "users", "reason": "conffile"}]
|
|
}
|
|
}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_files_changed():
|
|
report = {
|
|
"files": {
|
|
"changed": [
|
|
{
|
|
"path": "/etc/passwd",
|
|
"changes": {"content": {"old": "sha1", "new": "sha2"}},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
assert has_enforceable_drift(report) is True
|
|
|
|
|
|
def test_has_enforceable_drift_no_drift():
|
|
report = {
|
|
"packages": {"added": ["newpkg"]},
|
|
"services": {"enabled_added": ["new.service"]},
|
|
"users": {"added": ["bob"]},
|
|
"files": {"added": ["/opt/newfile"]},
|
|
}
|
|
assert has_enforceable_drift(report) is False
|
|
|
|
|
|
def test_enforcement_plan_packages_removed(monkeypatch, tmp_path: Path):
|
|
old_state = {
|
|
"roles": {
|
|
"services": [{"role_name": "nginx", "packages": ["nginx"]}],
|
|
"packages": [{"role_name": "vim", "package": "vim"}],
|
|
}
|
|
}
|
|
report = {"packages": {"removed": ["nginx", "vim"]}}
|
|
|
|
result = _enforcement_plan(report, old_state, tmp_path)
|
|
assert "nginx" in result.get("roles", [])
|
|
assert "vim" in result.get("roles", [])
|
|
assert "role_nginx" in result.get("tags", [])
|
|
|
|
|
|
def test_enforcement_plan_users_changed():
|
|
old_state = {
|
|
"roles": {"users": {"role_name": "users", "users": [{"name": "alice"}]}}
|
|
}
|
|
report = {"users": {"changed": [{"name": "alice", "changes": {"uid": {}}}]}}
|
|
|
|
result = _enforcement_plan(report, old_state, Path("/tmp"))
|
|
assert "users" in result.get("roles", [])
|
|
|
|
|
|
def test_enforcement_plan_files_removed(tmp_path: Path):
|
|
# Create the artifacts directory structure that _file_index expects
|
|
artifacts_dir = tmp_path / "artifacts" / "etc_custom"
|
|
artifacts_dir.mkdir(parents=True)
|
|
|
|
old_state = {
|
|
"roles": {
|
|
"etc_custom": {
|
|
"role_name": "etc_custom",
|
|
"managed_files": [
|
|
{"path": "/etc/custom.conf", "src_rel": "custom.conf"}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
report = {
|
|
"files": {"removed": [{"path": "/etc/custom.conf", "role": "etc_custom"}]}
|
|
}
|
|
|
|
result = _enforcement_plan(report, old_state, tmp_path)
|
|
assert "etc_custom" in result.get("roles", [])
|
|
|
|
|
|
def test_enforcement_plan_no_drift():
|
|
old_state = {"roles": {}}
|
|
report = {"packages": {"added": ["newpkg"]}}
|
|
|
|
result = _enforcement_plan(report, old_state, Path("/tmp"))
|
|
assert result.get("roles", []) == []
|
|
|
|
|
|
def test_bundle_from_input_tgz(monkeypatch, tmp_path: Path):
|
|
bundle_dir = tmp_path / "bundle"
|
|
bundle_dir.mkdir()
|
|
state_file = bundle_dir / "state.json"
|
|
state_file.write_text("{}", encoding="utf-8")
|
|
|
|
tar_path = tmp_path / "bundle.tgz"
|
|
with tarfile.open(tar_path, "w:gz") as tf:
|
|
tf.add(bundle_dir, arcname="bundle")
|
|
|
|
result = _bundle_from_input(str(tar_path), sops_mode=False)
|
|
assert result.dir.exists()
|
|
assert result.tempdir is not None
|
|
result.tempdir.cleanup()
|
|
|
|
|
|
def test_bundle_from_input_sops_mode_no_sops(monkeypatch, tmp_path: Path):
|
|
# Create a fake .sops file
|
|
sops_file = tmp_path / "harvest.sops"
|
|
sops_file.write_bytes(b"encrypted")
|
|
|
|
def fake_require():
|
|
raise SopsError("sops not found")
|
|
|
|
import enroll.diff as d
|
|
|
|
monkeypatch.setattr(d, "require_sops_cmd", fake_require)
|
|
|
|
with pytest.raises(SopsError):
|
|
_bundle_from_input(str(sops_file), sops_mode=True)
|
|
|
|
|
|
def test_report_markdown_basic():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2"},
|
|
"packages": {"added": ["vim"], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "## Packages" in result
|
|
assert "+ vim" in result
|
|
|
|
|
|
def test_report_markdown_with_enforcement_applied():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {
|
|
"status": "applied",
|
|
"tags": ["role_users"],
|
|
"returncode": 0,
|
|
"finished_at": "2024-01-01T00:01:00Z",
|
|
},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "Applied old harvest" in result
|
|
assert "role_users" in result
|
|
|
|
|
|
def test_report_markdown_with_enforcement_failed():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {
|
|
"status": "failed",
|
|
"returncode": 1,
|
|
},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "ansible-playbook failed" in result
|
|
|
|
|
|
def test_report_markdown_with_enforcement_skipped():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {
|
|
"status": "skipped",
|
|
"reason": "no drift",
|
|
},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "Skipped" in result
|
|
assert "no drift" in result
|
|
|
|
|
|
def test_report_markdown_with_version_ignored():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {
|
|
"added": [],
|
|
"removed": [],
|
|
"version_changed": [{"package": "vim", "old": "1.0", "new": "2.0"}],
|
|
"version_changed_ignored_count": 1,
|
|
},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "ignored 1" in result
|
|
|
|
|
|
def test_report_markdown_with_service_package_changes():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {
|
|
"enabled_added": [],
|
|
"enabled_removed": [],
|
|
"changed": [
|
|
{
|
|
"unit": "nginx.service",
|
|
"changes": {"packages": {"added": ["nginx-extra"], "removed": []}},
|
|
}
|
|
],
|
|
},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "packages added" in result
|
|
|
|
|
|
def test_report_markdown_empty():
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz"},
|
|
"new": {"input": "new.tar.gz"},
|
|
"packages": {},
|
|
"services": {},
|
|
"users": {},
|
|
"files": {},
|
|
}
|
|
result = _report_markdown(report)
|
|
assert "## Packages" in result
|
|
assert "## Services" in result
|
|
|
|
|
|
def test_spinner_start_stop(monkeypatch):
|
|
"""Test spinner can be started and stopped."""
|
|
import enroll.diff as d
|
|
|
|
# Mock threading to avoid actual thread creation
|
|
class FakeThread:
|
|
def __init__(self, target, name, daemon):
|
|
self.target = target
|
|
self.daemon = daemon
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def join(self, timeout):
|
|
pass
|
|
|
|
monkeypatch.setattr(d.threading, "Thread", FakeThread)
|
|
|
|
spinner = d._Spinner("test message")
|
|
spinner.start()
|
|
spinner.stop()
|
|
|
|
|
|
def test_spinner_already_started(monkeypatch):
|
|
"""Test spinner doesn't restart if already running."""
|
|
import enroll.diff as d
|
|
|
|
class FakeThread:
|
|
def __init__(self, target, name, daemon):
|
|
pass
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def join(self, timeout):
|
|
pass
|
|
|
|
monkeypatch.setattr(d.threading, "Thread", FakeThread)
|
|
|
|
spinner = d._Spinner("test message")
|
|
spinner.start()
|
|
spinner._thread = FakeThread(None, None, True) # Simulate already running
|
|
spinner.start() # Should return early
|
|
|
|
|
|
def test_spinner_stop_clears_line(monkeypatch, tmp_path):
|
|
"""Test spinner stop clears the line."""
|
|
import enroll.diff as d
|
|
import sys
|
|
|
|
class FakeThread:
|
|
def __init__(self, target, name, daemon):
|
|
pass
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def join(self, timeout):
|
|
pass
|
|
|
|
monkeypatch.setattr(d.threading, "Thread", FakeThread)
|
|
|
|
# Capture stderr writes
|
|
writes = []
|
|
original_write = sys.stderr.write
|
|
|
|
def capture_write(s):
|
|
writes.append(s)
|
|
return original_write(s)
|
|
|
|
monkeypatch.setattr(sys.stderr, "write", capture_write)
|
|
|
|
spinner = d._Spinner("test message")
|
|
spinner._last_len = 20
|
|
spinner.stop()
|
|
|
|
# Should have written clearing sequence
|
|
assert any("\r" in w for w in writes)
|
|
|
|
|
|
def test_should_show_spinner_disabled_env(monkeypatch):
|
|
"""Test spinner disabled via environment variable."""
|
|
import enroll.diff as d
|
|
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "1")
|
|
assert d._progress_enabled() is False
|
|
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "true")
|
|
assert d._progress_enabled() is False
|
|
|
|
monkeypatch.setenv("ENROLL_NO_PROGRESS", "yes")
|
|
assert d._progress_enabled() is False
|
|
|
|
|
|
def test_should_show_spinner_exception_on_isatty(monkeypatch):
|
|
"""Test spinner returns False when isatty raises exception."""
|
|
import enroll.diff as d
|
|
import sys
|
|
|
|
original_stderr = sys.stderr
|
|
|
|
class FakeStderr:
|
|
def isatty(self):
|
|
raise Exception("No tty")
|
|
|
|
monkeypatch.setattr(sys, "stderr", FakeStderr())
|
|
assert d._progress_enabled() is False
|
|
|
|
# Restore
|
|
monkeypatch.setattr(sys, "stderr", original_stderr)
|
|
|
|
|
|
def test_all_packages_from_state():
|
|
"""Test _all_packages extracts sorted package list."""
|
|
import enroll.diff as d
|
|
|
|
state = {
|
|
"inventory": {
|
|
"packages": {
|
|
"nginx": [{"version": "1.0"}],
|
|
"vim": [{"version": "2.0"}],
|
|
"bash": [{"version": "3.0"}],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = d._all_packages(state)
|
|
assert result == ["bash", "nginx", "vim"]
|
|
|
|
|
|
def test_all_packages_empty_state():
|
|
"""Test _all_packages with empty state."""
|
|
import enroll.diff as d
|
|
|
|
state = {"inventory": {"packages": {}}}
|
|
result = d._all_packages(state)
|
|
assert result == []
|
|
|
|
|
|
def test_roles_from_state():
|
|
"""Test _roles extracts roles from state."""
|
|
import enroll.diff as d
|
|
|
|
state = {"roles": {"web": {}, "db": {}}}
|
|
result = d._roles(state)
|
|
assert result == {"web": {}, "db": {}}
|
|
|
|
|
|
def test_roles_empty_state():
|
|
"""Test _roles with empty state."""
|
|
import enroll.diff as d
|
|
|
|
state = {}
|
|
result = d._roles(state)
|
|
assert result == {}
|
|
|
|
|
|
def test_pkg_version_key_with_multiple_versions():
|
|
"""Test _pkg_version_key handles multiple versions."""
|
|
import enroll.diff as d
|
|
|
|
entry = {
|
|
"installations": [
|
|
{"version": "1.0", "arch": "amd64"},
|
|
{"version": "2.0", "arch": "arm64"},
|
|
]
|
|
}
|
|
|
|
result = d._pkg_version_key(entry)
|
|
# Just check it returns a non-None value with version info
|
|
assert result is not None
|
|
assert len(result) > 0
|
|
|
|
|
|
def test_pkg_version_key_without_version():
|
|
"""Test _pkg_version_key skips entries without version."""
|
|
import enroll.diff as d
|
|
|
|
entry = {
|
|
"installations": [
|
|
{"arch": "amd64"}, # No version
|
|
]
|
|
}
|
|
|
|
result = d._pkg_version_key(entry)
|
|
assert result is None
|
|
|
|
|
|
def test_pkg_version_key_with_empty_installations():
|
|
"""Test _pkg_version_key with empty installations."""
|
|
import enroll.diff as d
|
|
|
|
entry = {"installations": []}
|
|
result = d._pkg_version_key(entry)
|
|
assert result is None
|
|
|
|
|
|
def test_pkg_version_key_without_installations():
|
|
"""Test _pkg_version_key without installations key."""
|
|
import enroll.diff as d
|
|
|
|
entry = {}
|
|
result = d._pkg_version_key(entry)
|
|
assert result is None
|
|
|
|
|
|
def test_pkg_version_key_with_direct_version():
|
|
"""Test _pkg_version_key with direct version field."""
|
|
import enroll.diff as d
|
|
|
|
entry = {"version": "1.2.3"}
|
|
result = d._pkg_version_key(entry)
|
|
assert result == "1.2.3"
|
|
|
|
|
|
def test_report_text_with_exclude_paths():
|
|
"""Test _report_text includes exclude paths."""
|
|
import enroll.diff as d
|
|
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
|
"filters": {"exclude_paths": ["/tmp/*", "/var/log/*"]},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
}
|
|
result = d._report_text(report)
|
|
assert "file exclude patterns" in result
|
|
assert "/tmp/*" in result
|
|
|
|
|
|
def test_report_text_with_ignore_package_versions():
|
|
"""Test _report_text includes ignore package versions message."""
|
|
import enroll.diff as d
|
|
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
|
"filters": {"ignore_package_versions": True},
|
|
"packages": {"version_changed_ignored_count": 5},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
}
|
|
result = d._report_text(report)
|
|
assert "package version drift: ignored" in result
|
|
assert "ignored 5 changes" in result
|
|
|
|
|
|
def test_report_text_with_enforcement_applied():
|
|
"""Test _report_text includes enforcement applied status."""
|
|
import enroll.diff as d
|
|
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {
|
|
"status": "applied",
|
|
"returncode": 0,
|
|
"tags": ["test"],
|
|
"finished_at": "2024-01-01T01:00:00Z",
|
|
},
|
|
}
|
|
result = d._report_text(report)
|
|
assert "Enforcement" in result
|
|
assert "applied old harvest via ansible-playbook" in result
|
|
assert "tags=test" in result
|
|
|
|
|
|
def test_report_text_with_enforcement_failed():
|
|
"""Test _report_text includes enforcement failed status."""
|
|
import enroll.diff as d
|
|
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {"status": "failed", "returncode": 1},
|
|
}
|
|
result = d._report_text(report)
|
|
assert "Enforcement" in result
|
|
assert "ansible-playbook failed" in result
|
|
|
|
|
|
def test_report_text_with_enforcement_skipped():
|
|
"""Test _report_text includes enforcement skipped status."""
|
|
import enroll.diff as d
|
|
|
|
report = {
|
|
"generated_at": "2024-01-01T00:00:00Z",
|
|
"old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
|
|
"new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
|
|
"packages": {"added": [], "removed": [], "version_changed": []},
|
|
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
"users": {"added": [], "removed": [], "changed": []},
|
|
"files": {"added": [], "removed": [], "changed": []},
|
|
"enforcement": {"status": "skipped", "reason": "no changes"},
|
|
}
|
|
result = d._report_text(report)
|
|
assert "Enforcement" in result
|
|
assert "skipped" in result
|
|
assert "no changes" in result
|