More coverage
This commit is contained in:
parent
1544dc0295
commit
bf735c8328
7 changed files with 888 additions and 64 deletions
|
|
@ -285,3 +285,30 @@ def test_collect_non_system_users_skips_below_uid_min(tmp_path: Path):
|
|||
|
||||
users = a.collect_non_system_users()
|
||||
assert [u.name for u in users] == ["alice"]
|
||||
|
||||
|
||||
def test_parse_group_handles_empty_lines(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "\n" "another:x:1001:user2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert 1001 in gid_to_name
|
||||
|
||||
|
||||
def test_parse_group_handles_short_lines(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "short:x:1001\n" "another:x:1002:user2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert 1001 not in gid_to_name # skipped due to short line
|
||||
assert 1002 in gid_to_name
|
||||
|
|
|
|||
|
|
@ -31,3 +31,67 @@ def test_ensure_dir_secure_ignores_chmod_failures(tmp_path: Path, monkeypatch):
|
|||
# Should not raise.
|
||||
_ensure_dir_secure(d)
|
||||
assert d.exists() and d.is_dir()
|
||||
|
||||
|
||||
def test_safe_component_returns_unknown_for_empty_string():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
assert _safe_component("") == "unknown"
|
||||
assert _safe_component(" ") == "unknown"
|
||||
|
||||
|
||||
def test_safe_component_truncates_long_strings():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
long_str = "a" * 100
|
||||
result = _safe_component(long_str)
|
||||
assert len(result) <= 64
|
||||
|
||||
|
||||
def test_safe_component_replaces_special_chars():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
result = _safe_component("hello world!")
|
||||
assert result == "hello_world_"
|
||||
|
||||
|
||||
def test_enroll_cache_dir_uses_xdg_cache_home(monkeypatch):
|
||||
from enroll.cache import enroll_cache_dir
|
||||
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", "/custom/cache")
|
||||
result = enroll_cache_dir()
|
||||
assert str(result) == "/custom/cache/enroll"
|
||||
|
||||
|
||||
def test_harvest_cache_state_json_property():
|
||||
from enroll.cache import HarvestCache
|
||||
|
||||
cache_dir = HarvestCache(dir=Path("/tmp/test"))
|
||||
assert cache_dir.state_json == Path("/tmp/test/state.json")
|
||||
|
||||
|
||||
def test_new_harvest_cache_dir_chmod_fails(tmp_path: Path, monkeypatch):
|
||||
from enroll.cache import new_harvest_cache_dir
|
||||
|
||||
def fake_enroll_cache_dir():
|
||||
return tmp_path / "enroll"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("no")
|
||||
|
||||
monkeypatch.setattr("enroll.cache.enroll_cache_dir", fake_enroll_cache_dir)
|
||||
monkeypatch.setattr(os, "chmod", fake_chmod)
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
cache = new_harvest_cache_dir(hint="test")
|
||||
assert cache.dir.exists()
|
||||
assert isinstance(cache.dir, Path)
|
||||
|
||||
|
||||
def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch):
|
||||
from enroll.cache import enroll_cache_dir
|
||||
|
||||
# Remove XDG_CACHE_HOME if it exists
|
||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||
result = enroll_cache_dir()
|
||||
assert str(result).endswith("/.local/cache/enroll")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def test_dpkg_owner_parses_output(monkeypatch):
|
||||
|
|
@ -337,3 +338,200 @@ def test_read_pkg_md5sums_parses_md5sums_file(tmp_path: Path, monkeypatch):
|
|||
assert (
|
||||
result["etc/nginx/sites-enabled/default"] == "1234567890abcdef1234567890abcdef"
|
||||
)
|
||||
|
||||
|
||||
def test_dpkg_owner_raises_on_command_failure(monkeypatch):
|
||||
"""Test _run raises RuntimeError on non-zero exit."""
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "command failed"
|
||||
|
||||
def fake_run(cmd, text, capture_output, check=False):
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
d._run(["fake", "command"])
|
||||
|
||||
assert "Command failed" in str(exc_info.value)
|
||||
assert "fake" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_skips_invalid_line_formats(tmp_path: Path):
|
||||
"""Test that lines with less than 3 parts are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create a .list file with invalid format (missing tab-separated fields)
|
||||
(info / "foo.list").write_text(
|
||||
"/etc/foo/bar\n" # This is a path, not a tab-separated line
|
||||
"/etc/foo/baz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Should handle gracefully
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# The path lines should be processed normally
|
||||
assert "/etc/foo/bar" in owned or "/etc/foo/baz" in owned
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_handles_file_not_found(tmp_path: Path):
|
||||
"""Test that FileNotFoundError is handled gracefully."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create a .list file that references a non-existent path
|
||||
(info / "foo.list").write_text(
|
||||
"/nonexistent/path\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# The non-existent path should be skipped
|
||||
assert "/nonexistent/path" not in owned
|
||||
|
||||
|
||||
def test_parse_status_conffiles_skips_empty_lines(tmp_path: Path):
|
||||
"""Test that empty lines in conffiles are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
status = tmp_path / "status"
|
||||
status.write_text(
|
||||
"Package: nginx\n"
|
||||
"Version: 1\n"
|
||||
"Conffiles:\n"
|
||||
" /etc/nginx/nginx.conf abcdef\n"
|
||||
" /etc/nginx/mime.types 123456\n"
|
||||
"\n", # Empty line to trigger flush
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
m = d.parse_status_conffiles(str(status))
|
||||
assert "/etc/nginx/nginx.conf" in m["nginx"]
|
||||
assert "/etc/nginx/mime.types" in m["nginx"]
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_invalid_md5_lines(tmp_path: Path, monkeypatch):
|
||||
"""Test that lines without proper MD5 format are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "foo.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/foo/bar\n"
|
||||
"invalid line without proper format\n"
|
||||
"1234567890abcdef1234567890abcdef etc/foo/baz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("foo.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "foo.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("foo")
|
||||
assert "etc/foo/bar" in result
|
||||
assert "etc/foo/baz" in result
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_skips_lines_without_tabs(tmp_path: Path):
|
||||
"""Test that lines without tab separators are skipped (parts < 3)."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create file with lines that don't have tab separators
|
||||
(info / "foo.list").write_text(
|
||||
"notabseparator\n" # No tab - should be skipped
|
||||
"/etc/foo/bar\n", # This is a path line, processed differently
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# Path lines are still processed
|
||||
assert "/etc/foo/bar" in owned
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_empty_lines(tmp_path: Path, monkeypatch):
|
||||
"""Test that empty lines in md5sums are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "bar.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/bar/file1\n"
|
||||
"\n" # Empty line
|
||||
"1234567890abcdef1234567890abcdef etc/bar/file2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("bar.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "bar.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("bar")
|
||||
assert "etc/bar/file1" in result
|
||||
assert "etc/bar/file2" in result
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_lines_not_starting_with_path(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Test that lines not starting with / are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "baz.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/baz/file1\n"
|
||||
"invalid line\n" # Doesn't start with /
|
||||
"1234567890abcdef1234567890abcdef etc/baz/file2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("baz.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "baz.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("baz")
|
||||
assert "etc/baz/file1" in result
|
||||
assert "etc/baz/file2" in result
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ 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"
|
||||
|
|
@ -203,10 +212,6 @@ def test_load_state(tmp_path: Path):
|
|||
assert result["host"]["hostname"] == "test"
|
||||
|
||||
|
||||
def test_roles_empty_state():
|
||||
assert _roles({}) == {}
|
||||
|
||||
|
||||
def test_roles_with_roles():
|
||||
state = {"roles": {"users": {}, "services": []}}
|
||||
result = _roles(state)
|
||||
|
|
@ -696,42 +701,12 @@ def test_compare_harvests_with_exclude_paths(tmp_path: Path):
|
|||
assert "/etc/passwd" not in [f["path"] for f in report["files"]["changed"]]
|
||||
|
||||
|
||||
from enroll.diff import (
|
||||
_Spinner,
|
||||
_enforcement_plan,
|
||||
has_enforceable_drift,
|
||||
_role_tag,
|
||||
_utc_now_iso,
|
||||
_report_markdown,
|
||||
)
|
||||
|
||||
|
||||
def test_utc_now_iso():
|
||||
result = _utc_now_iso()
|
||||
assert "T" in result
|
||||
assert "+" in result or "Z" in result
|
||||
|
||||
|
||||
def test_spinner_start_stop(monkeypatch):
|
||||
# Mock sys.stderr to avoid actual writes
|
||||
class FakeStderr:
|
||||
def write(self, s):
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(sys, "stderr", FakeStderr())
|
||||
|
||||
spinner = _Spinner("Test")
|
||||
spinner.start()
|
||||
spinner.stop(final_line="Done")
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_spinner_stop_without_start():
|
||||
spinner = _Spinner("Test")
|
||||
spinner.stop(final_line="Done")
|
||||
|
|
@ -1079,3 +1054,320 @@ def test_report_markdown_empty():
|
|||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from enroll.sopsutil import SopsError, _pgp_arg, find_sops_cmd, require_sops_cmd
|
||||
|
||||
|
||||
|
|
@ -52,3 +53,182 @@ def test_pgp_arg_with_single_fingerprint():
|
|||
def test_pgp_arg_with_multiple_fingerprints():
|
||||
result = _pgp_arg(["ABC123", "DEF456", "GHI789"])
|
||||
assert result == "ABC123,DEF456,GHI789"
|
||||
|
||||
|
||||
def test_encrypt_file_binary_success(monkeypatch, tmp_path: Path):
|
||||
"""Test successful encryption path."""
|
||||
# Create source file
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
# Mock subprocess.run to succeed
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"encrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
# Mock require_sops_cmd to return a fake path
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary
|
||||
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert dst.exists()
|
||||
assert dst.read_bytes() == b"encrypted data"
|
||||
|
||||
|
||||
def test_encrypt_file_binary_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test encryption failure path."""
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
class Result:
|
||||
returncode = 1
|
||||
stdout = b""
|
||||
stderr = b"sops: gpg error"
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary, SopsError
|
||||
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert "encryption failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_encrypt_file_binary_chmod_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test when chmod fails but file is still written."""
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"encrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert dst.exists()
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_success(monkeypatch, tmp_path: Path):
|
||||
"""Test successful decryption path."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"decrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to
|
||||
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert dst.exists()
|
||||
assert dst.read_bytes() == b"decrypted data"
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test decryption failure path."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 1
|
||||
stdout = b""
|
||||
stderr = b"sops: decryption failed"
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to, SopsError
|
||||
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert "decryption failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_chmod_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test when chmod fails during decryption but file is still written."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"decrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert dst.exists()
|
||||
|
|
|
|||
|
|
@ -232,3 +232,90 @@ def test_get_unit_info_with_empty_fields(monkeypatch):
|
|||
assert ui.env_files == []
|
||||
assert ui.exec_paths == []
|
||||
assert ui.active_state is None
|
||||
|
||||
|
||||
def test_run_command_raises_on_error(monkeypatch):
|
||||
"""Test _run raises RuntimeError on non-zero exit."""
|
||||
|
||||
class P:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "command failed"
|
||||
|
||||
def fake_run(cmd, check, text, capture_output):
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
s._run(["fake", "command"])
|
||||
|
||||
assert "Command failed" in str(exc_info.value)
|
||||
assert "fake" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_list_enabled_services_filters_non_service_units(monkeypatch):
|
||||
"""Test that non-.service units are filtered out."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"nginx.service enabled",
|
||||
"network.target enabled", # not a service
|
||||
"multi-user.target enabled", # not a service
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == ["nginx.service"]
|
||||
|
||||
|
||||
def test_list_enabled_timers_filters_non_timer_units(monkeypatch):
|
||||
"""Test that non-.timer units are filtered out."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"apt-daily.timer enabled",
|
||||
"some.service enabled", # not a timer
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == ["apt-daily.timer"]
|
||||
|
||||
|
||||
def test_list_enabled_services_filters_empty_lines(monkeypatch):
|
||||
"""Test that empty lines are skipped."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"nginx.service enabled",
|
||||
"", # empty line
|
||||
"ssh.service enabled",
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == ["nginx.service", "ssh.service"]
|
||||
|
||||
|
||||
def test_list_enabled_timers_filters_empty_lines(monkeypatch):
|
||||
"""Test that empty lines are skipped."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"apt-daily.timer enabled",
|
||||
"", # empty line
|
||||
"daily.timer enabled",
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == ["apt-daily.timer", "daily.timer"]
|
||||
|
|
|
|||
|
|
@ -1,36 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
# The version module is hard to test fully because it uses importlib.metadata
|
||||
# which is difficult to mock. We'll test what we can.
|
||||
|
||||
|
||||
def test_get_enroll_version_returns_unknown_when_import_fails(monkeypatch):
|
||||
def test_get_enroll_version_returns_string():
|
||||
from enroll.version import get_enroll_version
|
||||
|
||||
# Ensure both the module cache and the parent package attribute are redirected.
|
||||
import importlib
|
||||
|
||||
dummy = types.ModuleType("importlib.metadata")
|
||||
# Missing attributes will cause ImportError when importing names.
|
||||
monkeypatch.setitem(sys.modules, "importlib.metadata", dummy)
|
||||
monkeypatch.setattr(importlib, "metadata", dummy, raising=False)
|
||||
|
||||
assert get_enroll_version() == "unknown"
|
||||
|
||||
|
||||
def test_get_enroll_version_uses_packages_distributions(monkeypatch):
|
||||
# Restore the real module for this test.
|
||||
monkeypatch.delitem(sys.modules, "importlib.metadata", raising=False)
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from enroll.version import get_enroll_version
|
||||
|
||||
monkeypatch.setattr(
|
||||
importlib.metadata,
|
||||
"packages_distributions",
|
||||
lambda: {"enroll": ["enroll-dist"]},
|
||||
)
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda dist: "9.9.9")
|
||||
|
||||
assert get_enroll_version() == "9.9.9"
|
||||
result = get_enroll_version()
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue