165 lines
4.0 KiB
Python
165 lines
4.0 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import chguard.cli as cli
|
|
from chguard.cli import _restore_preview_rows, main
|
|
from chguard.db import connect, create_state, init_db
|
|
from chguard.restore import PlannedChange
|
|
|
|
|
|
class FakePath:
|
|
def relative_to(self, root: Path) -> Path:
|
|
return Path("link")
|
|
|
|
def lstat(self):
|
|
return SimpleNamespace(st_uid=123)
|
|
|
|
def stat(self):
|
|
raise AssertionError("restore preview must not follow symlinks")
|
|
|
|
|
|
def test_missing_and_type_changes_are_reported_as_skipped() -> None:
|
|
root = Path("/snapshot")
|
|
changes = [
|
|
PlannedChange(
|
|
root / "deleted.conf",
|
|
"missing",
|
|
"path does not exist",
|
|
False,
|
|
),
|
|
PlannedChange(root / "logs", "type", "file -> dir", False),
|
|
]
|
|
|
|
rows, counts, needs_root = _restore_preview_rows(
|
|
changes, root, current_uid=999
|
|
)
|
|
|
|
assert not needs_root
|
|
assert counts["skipped"] == 2
|
|
assert rows[Path("deleted.conf")]["skipped"] == "missing path"
|
|
assert rows[Path("logs")]["skipped"] == "found file, expected dir"
|
|
|
|
|
|
def test_unselected_scope_changes_are_not_reported() -> None:
|
|
root = Path("/snapshot")
|
|
changes = [
|
|
PlannedChange(root / "file.txt", "mode", "0o644 -> 0o600", False)
|
|
]
|
|
|
|
rows, counts, needs_root = _restore_preview_rows(
|
|
changes, root, current_uid=999
|
|
)
|
|
|
|
assert rows == {}
|
|
assert counts["mode"] == 0
|
|
assert not needs_root
|
|
|
|
|
|
def test_applicable_changes_use_lstat_for_privilege_check() -> None:
|
|
changes = [PlannedChange(FakePath(), "mode", "0o644 -> 0o600", True)]
|
|
|
|
rows, counts, needs_root = _restore_preview_rows(
|
|
changes, Path("/snapshot"), current_uid=999
|
|
)
|
|
|
|
assert needs_root
|
|
assert counts["mode"] == 1
|
|
assert "rw-r--r--" in rows[Path("link")]["mode"]
|
|
assert "rw-------" in rows[Path("link")]["mode"]
|
|
|
|
|
|
def test_restore_with_only_skipped_items_does_not_prompt(
|
|
tmp_path: Path, monkeypatch, capsys
|
|
) -> None:
|
|
root = tmp_path / "root"
|
|
root.mkdir()
|
|
|
|
conn = connect(tmp_path / "states.db")
|
|
init_db(conn)
|
|
with conn:
|
|
state_id = create_state(
|
|
conn, "baseline", str(root), os.getuid(), commit=False
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(state_id, "deleted.conf", "file", 0o644, os.getuid(), 0),
|
|
)
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"chguard",
|
|
"--db",
|
|
str(tmp_path / "states.db"),
|
|
"--restore",
|
|
"baseline",
|
|
],
|
|
)
|
|
|
|
main()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "missing path" in output
|
|
assert "No applicable changes" in output
|
|
|
|
|
|
def test_wrapper_uses_unique_auto_names_within_same_second(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
db_path = tmp_path / "states.db"
|
|
target = tmp_path / "target"
|
|
target.write_text("data", encoding="utf-8")
|
|
|
|
class FixedDatetime:
|
|
@classmethod
|
|
def now(cls) -> datetime:
|
|
return datetime(2026, 10, 10, 0, 10, 48)
|
|
|
|
monkeypatch.setattr(cli, "datetime", FixedDatetime)
|
|
monkeypatch.setattr(
|
|
cli.subprocess,
|
|
"run",
|
|
lambda command: SimpleNamespace(returncode=0),
|
|
)
|
|
|
|
for _ in range(2):
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"chguard",
|
|
"--db",
|
|
str(db_path),
|
|
"--",
|
|
"chmod",
|
|
"600",
|
|
str(target),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main()
|
|
|
|
assert exc_info.value.code == 0
|
|
|
|
conn = connect(db_path)
|
|
rows = conn.execute("SELECT name FROM states ORDER BY id").fetchall()
|
|
conn.close()
|
|
|
|
assert rows == [
|
|
("auto-20261010-001048",),
|
|
("auto-20261010-001048-1",),
|
|
]
|