Files
local-ci-cd-system/tests/python/test_web_actions.py
T
Simone d18321d4a0 feat(web): Stage E dashboard — wide cards, per-item actions, toast stack
A large, cohesive redesign of the web dashboard (all pieces depend on
each other, so this lands as one commit):

Layout / display:
- Host/Runners/VMware/Cache split into a Host-VMware-Runners-Cache info
  row; Host card keeps uptime/load/cores/mem-total now that CPU%/RAM
  are promoted to the status-header tiles (new gauge tiles, red/amber/
  green thresholds shared with the existing disk tile).
- VMware modules rendered as a table; Live VMs/Templates/Artifacts/
  Services/Maintenance span the full grid width (card--wide).
- Backups moved from a standalone Files-panel table into an expandable
  per-template row on the Templates panel (backups_by_template), with
  a per-backup Delete action and a per-row Backup button replacing the
  old host-card template dropdown.
- New Maintenance card: cleanup-orphans and cache-purge forms
  consolidated into one danger-zone card (red rule + row separators),
  each op's max-age control on its own row so it reads as the op's
  parameter, not decoration; cache purge gets an explicit "Empty all"
  button alongside the age-based "Purge".
- Artifacts gain a per-row Delete action.

Action/feedback layer:
- Every mutating route now sets HX-Trigger: ci:refresh on its result,
  so polled panels reload immediately instead of waiting for their
  next poll interval; _result_partial takes an explicit level
  (ok/info/error) independent of the ok bool, so e.g. an age-based
  purge that finds nothing to remove reads as neutral "info" instead
  of a misleading green "ok" or a red "error".
- Results render as toasts appended to a fixed #toast-stack (base.html)
  instead of swapping a single #action-slot -- concurrent actions (two
  backups running at once) now each keep their own toast instead of
  the later one clobbering the earlier. Per-state colour is a left
  stripe + tint (result-ok/info/error), fixing a cascade bug where the
  plain .toast background silently beat the state class.
- The confirm dialog is a real modal <dialog> (app.js: showModal(),
  Cancel/ESC clear the slot); confirming closes it immediately and
  shows a busy spinner toast in the stack (since a real backup can run
  for minutes) instead of leaving the dialog open with no feedback.
- host_backup now runs backup_template via asyncio.to_thread so a
  multi-GB 7z run no longer blocks the event loop (and every other
  dashboard poll) for its whole duration.
- Static assets are served with a mtime-based ?v= query param so a
  redeploy is picked up without a hard refresh.
2026-07-24 22:04:48 +02:00

958 lines
33 KiB
Python

"""Tests for the W6 mutating web routes (VM remove/cleanup, cache purge, host
backup, audit) -- SECURITY-CRITICAL: these routes destroy VMs, purge caches,
and back up templates.
No real ``vmrun``/``7z``/``systemctl``/disk writes outside ``tmp_path``: VM
backends are fakes/mocks, and ``operations/`` functions are spied on (not
replaced) so real side effects still land only inside ``tmp_path``.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import ci_orchestrator.operations.host as host_module
import ci_orchestrator.web.routes as routes_module
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
WebOidcConfig,
)
from ci_orchestrator.credentials import Credential, KeyringCredentialStore
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.vms import RemovalResult
from ci_orchestrator.web.app import create_app, get_backend
_SESSION_CRED_NAME = "TestWebSessionSecret"
_OIDC_CRED_NAME = "TestWebOidcClientSecret"
_FAKE_SESSION_SECRET = "sess-secret-do-not-leak-abc123"
_FAKE_OIDC_CLIENT_SECRET = "oidc-client-secret-do-not-leak-xyz987"
# ── shared fixtures / fakes ─────────────────────────────────────────────────
def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
secrets = {
_SESSION_CRED_NAME: _FAKE_SESSION_SECRET,
_OIDC_CRED_NAME: _FAKE_OIDC_CLIENT_SECRET,
}
def fake_get(self: KeyringCredentialStore, target: str) -> Credential:
if target not in secrets:
raise KeyError(target)
return Credential(username=target, password=secrets[target])
monkeypatch.setattr(KeyringCredentialStore, "get", fake_get)
def _make_config(root: Path, *, active: bool = False, allowed_users: list[str] | None = None) -> Config:
oidc = None
if active:
oidc = WebOidcConfig(
issuer="https://gitea.example.lan",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
client_secret_cred=_OIDC_CRED_NAME,
scopes=["openid", "profile"],
)
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
web=WebConfig(
enabled=active,
allowed_users=allowed_users if allowed_users is not None else [],
session_secret_cred=_SESSION_CRED_NAME,
audit_log=root / "web-audit.jsonl",
oidc=oidc,
),
)
class _FakeBackend:
"""Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked."""
def __init__(self, handles: list[VmHandle], ip: str | None = "192.168.79.201") -> None:
self._handles = handles
self._ip = ip
self.stop_calls: list[VmHandle] = []
self.delete_calls: list[VmHandle] = []
def list_running(self) -> list[VmHandle]:
return self._handles
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
return self._ip
def stop(self, handle: VmHandle, hard: bool = False) -> None:
self.stop_calls.append(handle)
def delete(self, handle: VmHandle) -> None:
self.delete_calls.append(handle)
def _make_vmx(root: Path, name: str = "job1") -> Path:
vm_dir = root / "build-vms" / name
vm_dir.mkdir(parents=True, exist_ok=True)
vmx = vm_dir / f"{name}.vmx"
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
return vmx
def _spy(real: Any) -> Any:
"""Wrap ``real`` so ``.calls`` records every ``(args, kwargs)`` call, still delegating."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
wrapper.calls.append((args, kwargs)) # type: ignore[attr-defined]
return real(*args, **kwargs)
wrapper.calls = [] # type: ignore[attr-defined]
return wrapper
def _csrf_from(text: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', text)
assert match is not None, f"no csrf field found in: {text[:300]!r}"
return match.group(1)
def _get_csrf(client: TestClient, route: str = "/") -> str:
resp = client.get(route)
assert resp.status_code == 200
return _csrf_from(resp.text)
def _last_audit_entry(audit_log: Path) -> AuditEntry:
entries = read_audit(audit_log, last=100)
assert entries, "expected at least one audit entry"
return entries[-1]
# ── 1. CSRF enforcement on every POST ───────────────────────────────────────
def test_post_without_csrf_field_is_403(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
client.get("/cache") # establish a session
resp = client.post("/cache/purge", data={})
assert resp.status_code == 403
def test_post_with_wrong_csrf_is_403(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
client.get("/cache") # establish a session with a real token
resp = client.post("/cache/purge", data={"csrf": "totally-not-the-real-token"})
assert resp.status_code == 403
def test_wrong_csrf_never_runs_the_operation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
client.get("/cache")
resp = client.post("/cache/purge", data={"csrf": "bogus", "stage": "execute"})
assert resp.status_code == 403
assert spy.calls == [] # type: ignore[attr-defined]
# ── 2. two-step confirm gate: preview always runs dry_run=True ─────────────
def test_cache_purge_preview_returns_confirm_partial_and_runs_dry_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert "Confirm" in resp.text
# Age-based purge confirm spells out the max-age dependency (#4).
assert "older than 30 day(s)" in resp.text
assert len(spy.calls) == 1 # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "dry-run"
assert entry.action == "cache.purge"
def test_cache_purge_preview_does_not_delete_anything(
tmp_path: Path,
) -> None:
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
entry_path = cache_root / "v1-deadbeef.tar.zst"
entry_path.write_bytes(b"x" * 10)
# Backdate it so it would be a purge candidate on a real run.
old = datetime.now(tz=UTC) - timedelta(days=999)
import os
os.utime(entry_path, (old.timestamp(), old.timestamp()))
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert entry_path.exists()
# ── 3. execute stage: real dry_run=False, audit "ok", result partial ───────
def test_cache_purge_execute_runs_dry_run_false_and_audits_ok(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert len(spy.calls) == 1 # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is False # type: ignore[attr-defined]
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "ok"
assert entry.action == "cache.purge"
def test_cache_purge_execute_actually_removes_expired_entries(tmp_path: Path) -> None:
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
entry_path = cache_root / "v1-deadbeef.tar.zst"
entry_path.write_bytes(b"x" * 10)
old = datetime.now(tz=UTC) - timedelta(days=999)
import os
os.utime(entry_path, (old.timestamp(), old.timestamp()))
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert not entry_path.exists()
def test_cache_purge_execute_nothing_removed_is_info_not_silent(tmp_path: Path) -> None:
# A fresh entry within retention -> age-based purge removes nothing. The
# result must say *why* (neutral "info", not a green "ok" that reads as a
# lie) and still ask panels to refresh via HX-Trigger (#3/#4).
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
(cache_root / "v1-fresh.tar.zst").write_bytes(b"x" * 10) # mtime = now
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert "Nothing removed" in resp.text
assert "max age" in resp.text
assert "result-info" in resp.text # neutral badge, not result-ok
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_cache_purge_max_age_override_from_form(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
# An explicit max age overrides the configured retention (30) for the
# age-based purge, and the confirm spells the chosen age out.
resp = client.post("/cache/purge", data={"csrf": csrf, "max_age_days": "7"})
assert resp.status_code == 200
assert "older than 7 day(s)" in resp.text
assert spy.calls[0][1]["retention_days"] == 7 # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
def test_cache_empty_all_preview_forces_and_confirms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all"})
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "Empty cargo cache" in resp.text
assert "entire cache" in resp.text
# The confirm form must carry mode=all through to the execute POST.
assert 'value="all"' in resp.text
assert spy.calls[0][1]["force"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
def test_cache_empty_all_execute_removes_regardless_of_age(tmp_path: Path) -> None:
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
fresh = cache_root / "v1-fresh.tar.zst"
fresh.write_bytes(b"x" * 10) # brand-new, well within the 30-day retention
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all", "stage": "execute"})
assert resp.status_code == 200
assert not fresh.exists()
assert "Freed" in resp.text
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_vm_remove_preview_then_execute_full_cycle(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.vms import remove_vm as real_remove_vm
spy = _spy(real_remove_vm)
monkeypatch.setattr(routes_module, "remove_vm", spy)
vmx = _make_vmx(tmp_path)
config = _make_config(tmp_path)
fake_backend = _FakeBackend([VmHandle(identifier=str(vmx), name="job1")])
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: fake_backend
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
csrf = _csrf_from(resp.text)
vmx_id = routes_module.encode_vmx_id(vmx)
preview = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf})
assert preview.status_code == 200
assert "Confirm" in preview.text
assert "would remove" in preview.text
assert spy.calls[-1][1]["dry_run"] is True # type: ignore[attr-defined]
assert fake_backend.stop_calls == []
assert fake_backend.delete_calls == []
assert _last_audit_entry(config.web.audit_log).result == "dry-run"
execute = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf, "stage": "execute"})
assert execute.status_code == 200
assert spy.calls[-1][1]["dry_run"] is False # type: ignore[attr-defined]
assert len(fake_backend.stop_calls) == 1
assert len(fake_backend.delete_calls) == 1
assert not vmx.parent.exists()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "ok"
assert entry.action == "vm.remove"
# ── 4. path containment ─────────────────────────────────────────────────────
def test_vm_remove_outside_build_vms_is_400_and_does_not_delete(tmp_path: Path) -> None:
outside_dir = tmp_path / "not-build-vms" / "evil"
outside_dir.mkdir(parents=True)
outside_vmx = outside_dir / "evil.vmx"
outside_vmx.write_text("keep me", encoding="utf-8")
config = _make_config(tmp_path)
fake_backend = _FakeBackend([])
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: fake_backend
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(outside_vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 400
assert outside_vmx.exists()
assert fake_backend.stop_calls == []
assert fake_backend.delete_calls == []
entries = read_audit(config.web.audit_log, last=100)
assert not any(e.result == "ok" for e in entries)
def test_vm_remove_vmx_directly_in_build_vms_is_400_not_wiping_base(tmp_path: Path) -> None:
"""A vmx whose parent IS build_vms would rmtree the whole clone base -- reject.
remove_vm() deletes vmx.parent; for ``build_vms/x.vmx`` that parent is
build_vms itself. The containment check must reject it so a crafted
vmx_id can't wipe every clone at once.
"""
config = _make_config(tmp_path)
build_vms = config.paths.build_vms
build_vms.mkdir(parents=True, exist_ok=True)
survivor = build_vms / "Clone_keep"
survivor.mkdir()
(survivor / "Clone_keep.vmx").write_text("keep", encoding="utf-8")
danger_vmx = build_vms / "x.vmx" # parent == build_vms itself
danger_vmx.write_text("x", encoding="utf-8")
fake_backend = _FakeBackend([])
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: fake_backend
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(danger_vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 400
assert build_vms.exists() # clone base NOT wiped
assert survivor.exists() # sibling clone survived
assert fake_backend.delete_calls == []
def test_vm_remove_malformed_vmx_id_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/not-valid-base64!!!/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 400
# ── 5. unauthenticated POST -> redirect to /login, no execution ────────────
def test_unauthenticated_post_redirects_to_login_and_never_runs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path, active=True, allowed_users=["simone"])
app = create_app(config)
client = TestClient(app, follow_redirects=False)
resp = client.post("/cache/purge", data={"csrf": "whatever", "stage": "execute"})
assert resp.status_code in (302, 303)
assert "/login" in resp.headers["location"]
assert spy.calls == [] # type: ignore[attr-defined]
assert not config.web.audit_log.exists()
# ── 6. job-race guard ───────────────────────────────────────────────────────
def _write_fresh_lock(config: Config) -> Path:
lock_path = config.paths.build_vms.parent / "vm-start.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text("job in progress", encoding="utf-8")
return lock_path
def test_vm_cleanup_execute_refused_409_when_lock_is_fresh(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.vms import cleanup_orphans as real_cleanup
spy = _spy(real_cleanup)
monkeypatch.setattr(routes_module, "cleanup_orphans", spy)
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
_write_fresh_lock(config)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 409
assert "job may be running" in resp.text.lower()
assert spy.calls == [] # type: ignore[attr-defined]
def test_vm_remove_execute_refused_409_when_lock_is_fresh(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.vms import remove_vm as real_remove_vm
spy = _spy(real_remove_vm)
monkeypatch.setattr(routes_module, "remove_vm", spy)
vmx = _make_vmx(tmp_path)
config = _make_config(tmp_path)
_write_fresh_lock(config)
fake_backend = _FakeBackend([VmHandle(identifier=str(vmx), name="job1")])
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: fake_backend
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 409
assert spy.calls == [] # type: ignore[attr-defined]
assert fake_backend.stop_calls == []
assert vmx.exists()
def test_vm_cleanup_preview_ignores_fresh_lock(tmp_path: Path) -> None:
"""A preview (dry-run) is always safe -- the lock guard only gates execute."""
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
_write_fresh_lock(config)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/cleanup", data={"csrf": csrf})
assert resp.status_code == 200
assert "Confirm" in resp.text
def test_vm_cleanup_execute_proceeds_when_lock_is_stale(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.vms import cleanup_orphans as real_cleanup
spy = _spy(real_cleanup)
monkeypatch.setattr(routes_module, "cleanup_orphans", spy)
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
lock_path = _write_fresh_lock(config)
stale = datetime.now(tz=UTC) - timedelta(minutes=90)
import os
os.utime(lock_path, (stale.timestamp(), stale.timestamp()))
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert len(spy.calls) == 1 # type: ignore[attr-defined]
assert _last_audit_entry(config.web.audit_log).result == "ok"
# ── 7. GET /audit renders written entries ───────────────────────────────────
def test_audit_view_renders_written_entries(tmp_path: Path) -> None:
config = _make_config(tmp_path)
append_audit(
config.web.audit_log,
AuditEntry(
ts="2026-07-23T10:00:00+00:00",
user="anonymous",
action="cache.purge",
target=str(config.cargo_cache.root),
result="ok",
detail="freed 123 bytes across 4 entrie(s)",
),
)
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert "Audit log" in resp.text
assert "cache.purge" in resp.text
assert "freed 123 bytes across 4 entrie(s)" in resp.text
def test_audit_view_empty_log_still_200(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
resp = client.get("/audit")
assert resp.status_code == 200
assert "Audit log" in resp.text
def test_audit_view_requires_auth_when_active(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
config = _make_config(tmp_path, active=True, allowed_users=["simone"])
app = create_app(config)
client = TestClient(app, follow_redirects=False)
resp = client.get("/audit")
assert resp.status_code in (302, 303)
assert "/login" in resp.headers["location"]
# ── 8. no secret value leaks into any response ──────────────────────────────
def test_no_secret_leaks_into_any_action_response(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
config = _make_config(tmp_path, active=True, allowed_users=["simone"])
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
from fastapi.responses import RedirectResponse
class _StubOAuthClient:
async def authorize_redirect(self, request: Any, redirect_uri: str | None = None, **kw: Any) -> Any:
return RedirectResponse(f"https://gitea.example.lan/authorize?r={redirect_uri}", 302)
async def authorize_access_token(self, request: Any, **kw: Any) -> dict[str, Any]:
return {"userinfo": {"preferred_username": "simone"}}
async def userinfo(self, **kw: Any) -> dict[str, Any]:
return {"preferred_username": "simone"}
app.state.oauth.gitea = _StubOAuthClient()
client = TestClient(app, follow_redirects=False)
client.get("/auth/callback") # logs in
bodies: list[str] = []
bodies.append(client.get("/").text)
csrf = _csrf_from(bodies[-1])
bodies.append(client.get("/cache").text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf}).text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"}).text)
bodies.append(client.get("/audit").text)
for body in bodies:
assert _FAKE_SESSION_SECRET not in body
assert _FAKE_OIDC_CLIENT_SECRET not in body
# ── extra coverage: helper edge cases + host.backup + degraded paths ───────
def test_is_contained_returns_false_on_resolve_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(self: Path, strict: bool = False) -> Path:
raise OSError("boom")
monkeypatch.setattr(Path, "resolve", _boom)
assert routes_module._is_contained(Path("/a/b"), Path("/a")) is False
def test_lock_is_fresh_missing_file_returns_false(tmp_path: Path) -> None:
assert routes_module._lock_is_fresh(tmp_path / "missing.lock") is False
def test_vm_remove_vmx_id_with_bad_base64_padding_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/a/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 400
def test_vm_cleanup_preview_with_non_numeric_max_age_hours_defaults(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "max_age_hours": "not-a-number"})
assert resp.status_code == 200
assert "older than 4h" in resp.text
def test_vm_remove_backend_unavailable_renders_error_partial_and_audits_error(
tmp_path: Path,
) -> None:
vmx = _make_vmx(tmp_path)
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf})
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
def test_vm_remove_execute_error_result_audits_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
vmx = _make_vmx(tmp_path)
config = _make_config(tmp_path)
def _failing_remove(backend: Any, target_vmx: Path, dry_run: bool = False) -> RemovalResult:
return RemovalResult(
target=str(target_vmx), stopped=True, deleted=False, dir_removed=False, error="delete failed"
)
monkeypatch.setattr(routes_module, "remove_vm", _failing_remove)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert "error" in resp.text.lower()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
assert "error removing" in entry.detail
def test_vm_remove_swallows_real_backend_error_never_500s(tmp_path: Path) -> None:
"""End-to-end (not mocked) proof that a real BackendError never 500s."""
class _FailingBackend:
def list_running(self) -> list[VmHandle]:
return []
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
return None
def stop(self, handle: VmHandle, hard: bool = False) -> None:
raise BackendError("stop failed")
def delete(self, handle: VmHandle) -> None:
raise BackendError("delete failed")
vmx = _make_vmx(tmp_path)
config = _make_config(tmp_path)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FailingBackend()
client = TestClient(app)
csrf = _get_csrf(client)
vmx_id = routes_module.encode_vmx_id(vmx)
resp = client.post(f"/vms/{vmx_id}/remove", data={"csrf": csrf}) # preview only, no retries
assert resp.status_code == 200
def test_cache_purge_exception_never_500s(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def _boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("disk exploded")
monkeypatch.setattr(routes_module, "purge_cargo_cache", _boom)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert "error" in resp.text.lower()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
def test_host_backup_missing_template_field_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/host/backup", data={"csrf": csrf})
assert resp.status_code == 400
def test_host_backup_unknown_template_name_is_400_not_audited(tmp_path: Path) -> None:
"""B6: a template name absent from ``list_templates()`` is rejected before
``backup_template`` is ever called -- a pure validation refusal, so (like
the VM-remove path-containment check) it is never audited.
"""
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post("/host/backup", data={"csrf": csrf, "template": "NoSuchTemplate"})
assert resp.status_code == 400
assert not config.web.audit_log.exists()
def test_host_backup_preview_then_execute_full_cycle(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
host_module, "_compress_7z", lambda source, archive: archive.write_bytes(b"fake-7z")
)
template_dir = tmp_path / "templates" / "LinuxBuild2404"
template_dir.mkdir(parents=True)
(template_dir / "LinuxBuild2404.vmx").write_text("x", encoding="utf-8")
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
preview = client.post(
"/host/backup", data={"csrf": csrf, "template": "LinuxBuild2404"}
)
assert preview.status_code == 200
assert "Confirm" in preview.text
assert "would archive" in preview.text
assert _last_audit_entry(config.web.audit_log).result == "dry-run"
execute = client.post(
"/host/backup", data={"csrf": csrf, "stage": "execute", "template": "LinuxBuild2404"}
)
assert execute.status_code == 200
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "ok"
assert entry.action == "host.backup"
def test_host_backup_execute_exception_never_500s(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("disk exploded")
monkeypatch.setattr(routes_module, "backup_template", _boom)
# Must be a name list_templates() actually discovers, or the B6
# known-template check 400s before backup_template is ever reached.
(tmp_path / "templates" / "X").mkdir(parents=True)
config = _make_config(tmp_path)
app = create_app(config)
client = TestClient(app)
csrf = _get_csrf(client)
resp = client.post(
"/host/backup", data={"csrf": csrf, "stage": "execute", "template": "X"}
)
assert resp.status_code == 200
assert "error" in resp.text.lower()
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "error"
# ── web/security.py: direct unit coverage of the CSRF primitives ──────────
def test_issue_csrf_is_idempotent_within_a_session() -> None:
from ci_orchestrator.web.security import issue_csrf
fake_request = type("_Req", (), {"session": {}})()
first = issue_csrf(fake_request) # type: ignore[arg-type]
second = issue_csrf(fake_request) # type: ignore[arg-type]
assert first == second
assert len(first) > 20
def test_validate_csrf_false_when_no_token_issued_yet() -> None:
from ci_orchestrator.web.security import validate_csrf
fake_request = type("_Req", (), {"session": {}})()
assert validate_csrf(fake_request, "anything") is False # type: ignore[arg-type]
def test_validate_csrf_false_when_nothing_submitted() -> None:
from ci_orchestrator.web.security import validate_csrf
fake_request = type("_Req", (), {"session": {"csrf": "real-token"}})()
assert validate_csrf(fake_request, None) is False # type: ignore[arg-type]
def test_validate_csrf_true_for_matching_token() -> None:
from ci_orchestrator.web.security import validate_csrf
fake_request = type("_Req", (), {"session": {"csrf": "real-token"}})()
assert validate_csrf(fake_request, "real-token") is True # type: ignore[arg-type]