feat(config): add [web]/[web.oidc] config + operations/audit.py (W3)
This commit is contained in:
@@ -72,3 +72,38 @@ type = "workstation"
|
||||
enabled = true
|
||||
# root = "/var/lib/ci/cache/cargo"
|
||||
retention_days = 30
|
||||
|
||||
# --------------------------------------------------------------------- web UI
|
||||
# Optional management web UI (`ci web serve`). Disabled unless `enabled = true`.
|
||||
# Server-rendered dashboard for VM/host management, auth'd via Gitea OIDC.
|
||||
#
|
||||
# SECURITY:
|
||||
# * `bind_host` defaults to 127.0.0.1 (localhost only). Only set it to a
|
||||
# LAN IP explicitly and deliberately -- never "0.0.0.0" (that exposes the
|
||||
# dashboard, which can kill VMs and purge caches, to every network the
|
||||
# host is reachable from).
|
||||
# * `session_secret_cred` and `[web.oidc] client_secret_cred` below are
|
||||
# KEYRING KEY NAMES, not the secrets themselves. The actual secret
|
||||
# values are never written to this file -- set them with:
|
||||
# ci creds set WebSessionSecret
|
||||
# ci creds set WebOidcClientSecret
|
||||
# (the `_cred` values here just tell the app which keyring entry to read
|
||||
# at request time).
|
||||
#
|
||||
# [web]
|
||||
# enabled = true
|
||||
# bind_host = "192.168.1.50" # explicit LAN IP; never 0.0.0.0
|
||||
# bind_port = 8800
|
||||
# audit_log = "/var/lib/ci/logs/web-audit.jsonl"
|
||||
# allowed_users = ["simone"] # Gitea usernames allowed to log in
|
||||
# session_secret_cred = "WebSessionSecret" # keyring KEY NAME, not the secret
|
||||
#
|
||||
# Gitea OIDC client registration: Gitea -> Settings -> Applications -> OAuth2,
|
||||
# redirect URI must exactly match `redirect_url` below (host+port+path).
|
||||
#
|
||||
# [web.oidc]
|
||||
# issuer = "https://gitea.lan"
|
||||
# client_id = "ci-web"
|
||||
# redirect_url = "http://192.168.1.50:8800/auth/callback"
|
||||
# client_secret_cred = "WebOidcClientSecret" # keyring KEY NAME, not the secret
|
||||
# scopes = ["openid", "profile"]
|
||||
|
||||
@@ -78,6 +78,40 @@ class CargoCacheConfig:
|
||||
retention_days: int = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebOidcConfig:
|
||||
"""Gitea OIDC client settings for the web UI.
|
||||
|
||||
``client_secret_cred`` is a **keyring key name**, never the secret
|
||||
itself — the actual client secret is set out-of-band via
|
||||
``ci creds set`` and read from :class:`~ci_orchestrator.credentials.
|
||||
KeyringCredentialStore` at request time.
|
||||
"""
|
||||
|
||||
issuer: str
|
||||
client_id: str
|
||||
redirect_url: str
|
||||
client_secret_cred: str = "WebOidcClientSecret"
|
||||
scopes: list[str] = field(default_factory=lambda: ["openid", "profile"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebConfig:
|
||||
"""Web UI settings (``ci web serve``). Disabled by default.
|
||||
|
||||
``session_secret_cred`` is a **keyring key name**, never the secret
|
||||
itself — same pattern as :attr:`WebOidcConfig.client_secret_cred`.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
bind_host: str = "127.0.0.1"
|
||||
bind_port: int = 8800
|
||||
audit_log: Path = Path("/var/lib/ci/logs/web-audit.jsonl")
|
||||
allowed_users: list[str] = field(default_factory=list)
|
||||
session_secret_cred: str = "WebSessionSecret"
|
||||
oidc: WebOidcConfig | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Top-level configuration object."""
|
||||
@@ -89,6 +123,7 @@ class Config:
|
||||
guest_cred_target: str = "BuildVMGuest"
|
||||
ip_pool: IpPoolConfig | None = None
|
||||
cargo_cache: CargoCacheConfig = field(default_factory=CargoCacheConfig)
|
||||
web: WebConfig = field(default_factory=WebConfig)
|
||||
|
||||
|
||||
def _platform_defaults() -> Paths:
|
||||
@@ -137,6 +172,45 @@ def _apply_env(paths: Paths, env: dict[str, str]) -> Paths:
|
||||
return _merge_paths(paths, overrides)
|
||||
|
||||
|
||||
def _parse_web_oidc(oidc_section: dict[str, Any]) -> WebOidcConfig | None:
|
||||
"""Parse ``[web.oidc]``. Missing required keys degrade to ``None`` -- never raise."""
|
||||
if not oidc_section:
|
||||
return None
|
||||
issuer = oidc_section.get("issuer")
|
||||
client_id = oidc_section.get("client_id")
|
||||
redirect_url = oidc_section.get("redirect_url")
|
||||
if not issuer or not client_id or not redirect_url:
|
||||
return None
|
||||
|
||||
scopes = oidc_section.get("scopes")
|
||||
return WebOidcConfig(
|
||||
issuer=str(issuer),
|
||||
client_id=str(client_id),
|
||||
redirect_url=str(redirect_url),
|
||||
client_secret_cred=str(oidc_section.get("client_secret_cred", "WebOidcClientSecret")),
|
||||
scopes=[str(s) for s in scopes] if isinstance(scopes, list) else ["openid", "profile"],
|
||||
)
|
||||
|
||||
|
||||
def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
|
||||
"""Parse ``[web]`` (and nested ``[web.oidc]``). Absent section -> disabled defaults."""
|
||||
if not web_section:
|
||||
return WebConfig()
|
||||
|
||||
allowed_users = web_section.get("allowed_users")
|
||||
audit_log = web_section.get("audit_log")
|
||||
|
||||
return WebConfig(
|
||||
enabled=bool(web_section.get("enabled", False)),
|
||||
bind_host=str(web_section.get("bind_host", "127.0.0.1")),
|
||||
bind_port=int(web_section.get("bind_port", 8800)),
|
||||
audit_log=Path(audit_log) if audit_log else WebConfig().audit_log,
|
||||
allowed_users=[str(u) for u in allowed_users] if isinstance(allowed_users, list) else [],
|
||||
session_secret_cred=str(web_section.get("session_secret_cred", "WebSessionSecret")),
|
||||
oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}),
|
||||
)
|
||||
|
||||
|
||||
def load_config(
|
||||
config_path: Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
@@ -173,6 +247,7 @@ def load_config(
|
||||
guest_cred_target = env.get("CI_GUEST_CRED_TARGET", "BuildVMGuest")
|
||||
ip_pool: IpPoolConfig | None = None
|
||||
cargo_cache_section: dict[str, Any] = {}
|
||||
web_section: dict[str, Any] = {}
|
||||
|
||||
if toml_path and toml_path.is_file():
|
||||
data = _load_toml(toml_path)
|
||||
@@ -203,6 +278,7 @@ def load_config(
|
||||
lease_file=Path(pool_section.get("lease_file") or _lease_default),
|
||||
)
|
||||
cargo_cache_section = data.get("cargo_cache", {}) or {}
|
||||
web_section = data.get("web", {}) or {}
|
||||
|
||||
paths = _apply_env(paths, env)
|
||||
|
||||
@@ -214,6 +290,8 @@ def load_config(
|
||||
retention_days=int(cargo_cache_section.get("retention_days", 30)),
|
||||
)
|
||||
|
||||
web = _parse_web_config(web_section)
|
||||
|
||||
return Config(
|
||||
paths=paths,
|
||||
backend=backend,
|
||||
@@ -222,6 +300,7 @@ def load_config(
|
||||
guest_cred_target=guest_cred_target,
|
||||
ip_pool=ip_pool,
|
||||
cargo_cache=cargo_cache,
|
||||
web=web,
|
||||
)
|
||||
|
||||
|
||||
@@ -231,5 +310,7 @@ __all__ = [
|
||||
"Config",
|
||||
"IpPoolConfig",
|
||||
"Paths",
|
||||
"WebConfig",
|
||||
"WebOidcConfig",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pure audit-trail operations shared by the CLI and (future) web UI.
|
||||
|
||||
Append-only JSONL log of mutating actions (VM remove/cleanup, cache purge,
|
||||
host backup, ...). Zero ``click``, zero ``print`` — this module only ever
|
||||
touches the filesystem and stdlib ``json``. Presentation stays in the CLI
|
||||
command wrappers and, later, the web routes.
|
||||
|
||||
The log file is append-only by construction: :func:`append_audit` always
|
||||
opens in append mode and never truncates, so a crash mid-write can only ever
|
||||
corrupt the last line, not earlier history. :func:`read_audit` tolerates
|
||||
that: malformed or torn lines are skipped rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEntry:
|
||||
"""A single audit-log record for a mutating web-UI/CLI action."""
|
||||
|
||||
ts: str # ISO 8601 UTC
|
||||
user: str
|
||||
action: str # "vm.remove" | "vm.cleanup" | "cache.purge" | "host.backup"
|
||||
target: str
|
||||
result: str # "ok" | "error" | "dry-run"
|
||||
detail: str
|
||||
|
||||
|
||||
def append_audit(log_path: Path, entry: AuditEntry) -> None:
|
||||
"""Append ``entry`` as one JSON line to ``log_path``. Never truncates.
|
||||
|
||||
Creates the parent directory if needed. Opens in append mode ("a"), so
|
||||
existing content is always preserved.
|
||||
"""
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(asdict(entry)) + "\n")
|
||||
|
||||
|
||||
def _parse_line(line: str) -> AuditEntry | None:
|
||||
"""Parse one JSONL line into an :class:`AuditEntry`, or ``None`` if malformed."""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
return AuditEntry(
|
||||
ts=str(data["ts"]),
|
||||
user=str(data["user"]),
|
||||
action=str(data["action"]),
|
||||
target=str(data["target"]),
|
||||
result=str(data["result"]),
|
||||
detail=str(data["detail"]),
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def read_audit(log_path: Path, *, last: int = 100) -> list[AuditEntry]:
|
||||
"""Return up to the last ``last`` entries from ``log_path``, oldest-first.
|
||||
|
||||
Missing file returns ``[]``. Malformed or torn lines (e.g. a crash
|
||||
mid-write) are skipped rather than raising.
|
||||
"""
|
||||
if not log_path.is_file():
|
||||
return []
|
||||
|
||||
entries: list[AuditEntry] = []
|
||||
with log_path.open("r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
entry = _parse_line(line)
|
||||
if entry is not None:
|
||||
entries.append(entry)
|
||||
|
||||
if last <= 0:
|
||||
return []
|
||||
return entries[-last:]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuditEntry",
|
||||
"append_audit",
|
||||
"read_audit",
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for the ``[web]`` / ``[web.oidc]`` config section (W3 of the web-UI plan).
|
||||
|
||||
Contract: ``[web]`` absent means the web UI is disabled by default; secrets
|
||||
are NEVER read from TOML — only keyring key *names* (``client_secret_cred``,
|
||||
``session_secret_cred``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ci_orchestrator.config import WebConfig, WebOidcConfig, load_config
|
||||
|
||||
|
||||
def _paths_section() -> str:
|
||||
return (
|
||||
'[paths]\nroot = "/var/lib/ci"\ntemplates = "/var/lib/ci/templates"\n'
|
||||
'build_vms = "/var/lib/ci/build-vms"\nartifacts = "/var/lib/ci/artifacts"\n'
|
||||
'keys = "/var/lib/ci/keys"\n'
|
||||
)
|
||||
|
||||
|
||||
def _write_toml(tmp_path: Path, extra: str = "") -> Path:
|
||||
toml = tmp_path / "config.toml"
|
||||
toml.write_text(_paths_section() + extra, encoding="utf-8")
|
||||
return toml
|
||||
|
||||
|
||||
# ── [web] absent ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_web_absent_defaults_to_disabled(tmp_path: Path) -> None:
|
||||
cfg = load_config(config_path=_write_toml(tmp_path), env={})
|
||||
|
||||
assert cfg.web == WebConfig()
|
||||
assert cfg.web.enabled is False
|
||||
assert cfg.web.oidc is None
|
||||
|
||||
|
||||
def test_web_absent_when_no_toml_file_at_all(tmp_path: Path) -> None:
|
||||
# No config file at all -> pure defaults.
|
||||
cfg = load_config(config_path=tmp_path / "nope.toml", env={})
|
||||
|
||||
assert cfg.web.enabled is False
|
||||
assert cfg.web.oidc is None
|
||||
|
||||
|
||||
# ── [web] present ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_web_section_populates_fields(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[web]\n"
|
||||
"enabled = true\n"
|
||||
'bind_host = "192.168.1.50"\n'
|
||||
"bind_port = 9000\n"
|
||||
'audit_log = "/var/lib/ci/logs/custom-audit.jsonl"\n'
|
||||
'allowed_users = ["simone", "alice"]\n'
|
||||
'session_secret_cred = "MySessionCred"\n'
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.enabled is True
|
||||
assert cfg.web.bind_host == "192.168.1.50"
|
||||
assert cfg.web.bind_port == 9000
|
||||
assert cfg.web.audit_log == Path("/var/lib/ci/logs/custom-audit.jsonl")
|
||||
assert cfg.web.allowed_users == ["simone", "alice"]
|
||||
assert cfg.web.session_secret_cred == "MySessionCred"
|
||||
assert cfg.web.oidc is None
|
||||
|
||||
|
||||
def test_web_section_partial_uses_defaults_for_rest(tmp_path: Path) -> None:
|
||||
extra = "\n[web]\nenabled = true\n"
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.enabled is True
|
||||
assert cfg.web.bind_host == "127.0.0.1"
|
||||
assert cfg.web.bind_port == 8800
|
||||
assert cfg.web.audit_log == Path("/var/lib/ci/logs/web-audit.jsonl")
|
||||
assert cfg.web.allowed_users == []
|
||||
assert cfg.web.session_secret_cred == "WebSessionSecret"
|
||||
|
||||
|
||||
# ── [web.oidc] ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_web_oidc_section_populates_fields(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[web]\nenabled = true\n"
|
||||
"\n[web.oidc]\n"
|
||||
'issuer = "https://gitea.lan"\n'
|
||||
'client_id = "ci-web"\n'
|
||||
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
|
||||
'client_secret_cred = "MyOidcCred"\n'
|
||||
'scopes = ["openid", "profile", "email"]\n'
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.oidc == WebOidcConfig(
|
||||
issuer="https://gitea.lan",
|
||||
client_id="ci-web",
|
||||
redirect_url="http://192.168.1.50:8800/auth/callback",
|
||||
client_secret_cred="MyOidcCred",
|
||||
scopes=["openid", "profile", "email"],
|
||||
)
|
||||
|
||||
|
||||
def test_web_oidc_section_defaults_client_secret_cred_and_scopes(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[web.oidc]\n"
|
||||
'issuer = "https://gitea.lan"\n'
|
||||
'client_id = "ci-web"\n'
|
||||
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.oidc is not None
|
||||
assert cfg.web.oidc.client_secret_cred == "WebOidcClientSecret"
|
||||
assert cfg.web.oidc.scopes == ["openid", "profile"]
|
||||
|
||||
|
||||
def test_web_oidc_missing_required_key_yields_none_not_crash(tmp_path: Path) -> None:
|
||||
# Missing redirect_url -> oidc must degrade to None, never raise.
|
||||
extra = (
|
||||
"\n[web.oidc]\n"
|
||||
'issuer = "https://gitea.lan"\n'
|
||||
'client_id = "ci-web"\n'
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.oidc is None
|
||||
|
||||
|
||||
def test_web_oidc_empty_section_yields_none(tmp_path: Path) -> None:
|
||||
extra = "\n[web.oidc]\n"
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.oidc is None
|
||||
|
||||
|
||||
# ── security: no secret values ever stored ──────────────────────────────────
|
||||
|
||||
|
||||
def test_no_secret_value_fields_exist_on_web_config() -> None:
|
||||
"""Only keyring key *names* are stored -- never a secret value."""
|
||||
field_names = set(WebConfig.__dataclass_fields__)
|
||||
assert "session_secret" not in field_names
|
||||
assert "session_secret_value" not in field_names
|
||||
assert "session_secret_cred" in field_names
|
||||
|
||||
|
||||
def test_no_secret_value_fields_exist_on_web_oidc_config() -> None:
|
||||
field_names = set(WebOidcConfig.__dataclass_fields__)
|
||||
assert "client_secret" not in field_names
|
||||
assert "client_secret_value" not in field_names
|
||||
assert "client_secret_cred" in field_names
|
||||
|
||||
|
||||
def test_toml_secret_looking_value_is_never_surfaced_as_a_secret(tmp_path: Path) -> None:
|
||||
"""Even if a TOML author mistakenly writes a secret-shaped value under
|
||||
``client_secret_cred``/``session_secret_cred``, config.py only ever
|
||||
treats it as a keyring key *name* -- there is no code path that reads a
|
||||
``client_secret``/``session_secret`` key from TOML at all.
|
||||
"""
|
||||
extra = (
|
||||
"\n[web]\nenabled = true\n"
|
||||
'session_secret_cred = "sk-not-a-real-secret-just-a-name"\n'
|
||||
"\n[web.oidc]\n"
|
||||
'issuer = "https://gitea.lan"\n'
|
||||
'client_id = "ci-web"\n'
|
||||
'redirect_url = "http://192.168.1.50:8800/auth/callback"\n'
|
||||
'client_secret_cred = "another-cred-name"\n'
|
||||
# If config.py ever started reading these, this test would need to
|
||||
# change -- their presence here proves they are ignored.
|
||||
'client_secret = "should-never-be-read"\n'
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.session_secret_cred == "sk-not-a-real-secret-just-a-name"
|
||||
assert cfg.web.oidc is not None
|
||||
assert cfg.web.oidc.client_secret_cred == "another-cred-name"
|
||||
# No attribute on WebOidcConfig can ever hold "should-never-be-read".
|
||||
for value in vars(cfg.web.oidc).values():
|
||||
assert value != "should-never-be-read"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user