"""Tests for the pure ``operations.audit`` layer (W3 of the web-UI plan). Covers ``append_audit``/``read_audit`` — the append-only JSONL trail the future web UI writes to on every mutating action. Zero ``click``/``print`` in the module under test; only stdlib ``json`` is used. """ from __future__ import annotations import json from pathlib import Path from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit def _entry(**overrides: object) -> AuditEntry: base: dict[str, object] = { "ts": "2026-07-23T12:00:00+00:00", "user": "simone", "action": "vm.remove", "target": "CI-build-abc123", "result": "ok", "detail": "removed via web UI", } base.update(overrides) return AuditEntry(**base) # type: ignore[arg-type] # ── append_audit / read_audit round-trip ──────────────────────────────────── def test_append_then_read_round_trip(tmp_path: Path) -> None: log_path = tmp_path / "audit" / "web-audit.jsonl" entry = _entry() append_audit(log_path, entry) result = read_audit(log_path) assert result == [entry] def test_append_creates_parent_dir(tmp_path: Path) -> None: log_path = tmp_path / "nested" / "dir" / "web-audit.jsonl" append_audit(log_path, _entry()) assert log_path.is_file() def test_append_writes_one_json_line(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" entry = _entry() append_audit(log_path, entry) lines = log_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1 parsed = json.loads(lines[0]) assert parsed == { "ts": entry.ts, "user": entry.user, "action": entry.action, "target": entry.target, "result": entry.result, "detail": entry.detail, } def test_append_never_truncates(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" first = _entry(target="vm-1") second = _entry(target="vm-2") append_audit(log_path, first) append_audit(log_path, second) result = read_audit(log_path) assert result == [first, second] lines = log_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 2 # ── read_audit ─────────────────────────────────────────────────────────────── def test_read_audit_missing_file_returns_empty_list(tmp_path: Path) -> None: log_path = tmp_path / "does-not-exist.jsonl" assert read_audit(log_path) == [] def test_read_audit_returns_last_n_in_file_order(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" entries = [_entry(target=f"vm-{i}") for i in range(5)] for entry in entries: append_audit(log_path, entry) result = read_audit(log_path, last=3) assert result == entries[-3:] def test_read_audit_default_last_is_100(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" entries = [_entry(target=f"vm-{i}") for i in range(150)] for entry in entries: append_audit(log_path, entry) result = read_audit(log_path) assert len(result) == 100 assert result == entries[-100:] def test_read_audit_skips_malformed_lines(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" good = _entry(target="vm-good") append_audit(log_path, good) with log_path.open("a", encoding="utf-8") as fh: fh.write("{not valid json torn line\n") fh.write("\n") # blank line, also skipped result = read_audit(log_path) assert result == [good] def test_read_audit_skips_non_object_json_line(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" good = _entry(target="vm-good") append_audit(log_path, good) with log_path.open("a", encoding="utf-8") as fh: fh.write("[1, 2, 3]\n") # valid JSON, but not an object fh.write('"just a string"\n') result = read_audit(log_path) assert result == [good] def test_read_audit_skips_object_missing_required_key(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" good = _entry(target="vm-good") append_audit(log_path, good) with log_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps({"ts": "x", "user": "simone"}) + "\n") # missing keys result = read_audit(log_path) assert result == [good] def test_read_audit_last_zero_returns_empty_list(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" append_audit(log_path, _entry()) assert read_audit(log_path, last=0) == [] def test_read_audit_skips_torn_last_line(tmp_path: Path) -> None: log_path = tmp_path / "web-audit.jsonl" good = _entry(target="vm-good") append_audit(log_path, good) with log_path.open("a", encoding="utf-8") as fh: fh.write('{"ts": "2026-07-23T12:00:01+00:00", "user": "simone", "acti') result = read_audit(log_path) assert result == [good]