"""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("/executions") 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" # ── POST /host/delete-template ───────────────────────────────────────────── def test_host_delete_template_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/delete-template", data={"csrf": csrf}) assert resp.status_code == 400 def test_host_delete_template_unknown_template_name_is_400_not_audited(tmp_path: Path) -> None: """Same B6 containment discipline as ``/host/backup``: an arbitrary name is rejected before ``delete_template`` is ever called.""" config = _make_config(tmp_path) app = create_app(config) client = TestClient(app) csrf = _get_csrf(client) resp = client.post( "/host/delete-template", data={"csrf": csrf, "template": "NoSuchTemplate"} ) assert resp.status_code == 400 assert not config.web.audit_log.exists() def test_host_delete_template_preview_then_execute_full_cycle(tmp_path: Path) -> None: 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/delete-template", data={"csrf": csrf, "template": "LinuxBuild2404"} ) assert preview.status_code == 200 assert "Confirm" in preview.text assert "permanently delete" in preview.text assert _last_audit_entry(config.web.audit_log).result == "dry-run" assert template_dir.is_dir() # nothing removed on the preview stage execute = client.post( "/host/delete-template", 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.delete_template" assert not template_dir.exists() def test_host_delete_template_execute_exception_never_500s( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: def _boom(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("device busy") monkeypatch.setattr(routes_module, "delete_template", _boom) (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/delete-template", data={"csrf": csrf, "stage": "execute", "template": "X"} ) assert resp.status_code == 200 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]