"""Tests for the Stage-E artifact-delete route + ``operations.inventory`` helpers (``delete_artifact``, ``backups_by_template``). SECURITY-CRITICAL: ``/artifacts/{id}/delete`` unlinks files under ``config.paths.artifacts`` for an authenticated web user. These tests assert the same discipline the other mutating routes carry: CSRF, the two-step confirm gate, path containment, and an audit entry per stage. No file outside ``tmp_path`` is ever touched. """ from __future__ import annotations import re from pathlib import Path from fastapi.testclient import TestClient from ci_orchestrator.config import ( BackendConfig, CargoCacheConfig, Config, Paths, WebConfig, ) from ci_orchestrator.operations.audit import read_audit from ci_orchestrator.operations.inventory import ( FileEntry, backups_by_template, delete_artifact, delete_backup, ) from ci_orchestrator.web.app import create_app from ci_orchestrator.web.routes import encode_artifact_id def _make_config(root: Path) -> Config: 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(audit_log=root / "web-audit.jsonl"), ) 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) -> str: # The dashboard's Maintenance card always carries a CSRF field, even when # there are no artifacts/VMs/templates to render an action form for. resp = client.get("/") assert resp.status_code == 200 return _csrf_from(resp.text) def _make_artifact(config: Config, name: str = "build-42.tar.zst") -> Path: config.paths.artifacts.mkdir(parents=True, exist_ok=True) path = config.paths.artifacts / name path.write_bytes(b"payload") return path # ── operations.inventory.delete_artifact ──────────────────────────────────── def test_delete_artifact_dry_run_keeps_the_file(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_artifact(config) result = delete_artifact(config, path.name, dry_run=True) assert result.ok assert path.exists() def test_delete_artifact_execute_removes_the_file(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_artifact(config) result = delete_artifact(config, path.name, dry_run=False) assert result.ok assert not path.exists() def test_delete_artifact_missing_file_is_error(tmp_path: Path) -> None: config = _make_config(tmp_path) config.paths.artifacts.mkdir(parents=True, exist_ok=True) result = delete_artifact(config, "nope.zip", dry_run=False) assert not result.ok assert result.error is not None def test_delete_artifact_traversal_is_refused(tmp_path: Path) -> None: config = _make_config(tmp_path) config.paths.artifacts.mkdir(parents=True, exist_ok=True) secret = tmp_path / "secret.txt" secret.write_text("do not delete", encoding="utf-8") result = delete_artifact(config, "../secret.txt", dry_run=False) assert not result.ok assert secret.exists() # the traversal never reached outside artifacts/ # ── operations.inventory.backups_by_template ──────────────────────────────── def test_backups_by_template_groups_and_orders_newest_first(tmp_path: Path) -> None: config = _make_config(tmp_path) backups = config.paths.root / "backups" backups.mkdir(parents=True) import os def _touch(name: str, mtime: float) -> None: p = backups / name p.write_bytes(b"x") os.utime(p, (mtime, mtime)) # Real archive names carry a ``_%Y%m%d_%H%M%S`` timestamp (two ``_`` # chunks), exactly what operations/host.py::backup_template and the # ``template backup`` CLI write -- the parser must strip *both* the date # and the time to recover the template name (regression: it used to strip # only the final chunk and grouped under "LinuxBuild2404_20260715"). _touch("Template_LinuxBuild2404_20260715_101500.7z", 1000.0) _touch("Template_LinuxBuild2404_20260722_145001.7z", 2000.0) # newer _touch("Template_WinBuild2025_20260720_030000.7z", 1500.0) _touch("not-a-template-archive.7z", 1600.0) # ignored grouped = backups_by_template(config) assert set(grouped) == {"LinuxBuild2404", "WinBuild2025"} linux = grouped["LinuxBuild2404"] assert [e.name for e in linux] == [ "Template_LinuxBuild2404_20260722_145001.7z", "Template_LinuxBuild2404_20260715_101500.7z", ] assert all(isinstance(e, FileEntry) for e in linux) # ── operations.inventory.delete_backup ────────────────────────────────────── def _make_backup(config: Config, name: str = "Template_LinuxBuild2404_20260722_145001.7z") -> Path: backups = config.paths.root / "backups" backups.mkdir(parents=True, exist_ok=True) path = backups / name path.write_bytes(b"archive") return path def test_delete_backup_dry_run_reports_without_unlinking(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_backup(config) result = delete_backup(config, path.name, dry_run=True) assert result.ok is True assert result.target == path.resolve() assert path.exists(), "dry_run must not delete" def test_delete_backup_execute_unlinks(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_backup(config) result = delete_backup(config, path.name, dry_run=False) assert result.ok is True assert not path.exists() def test_delete_backup_refuses_non_backup_name(tmp_path: Path) -> None: config = _make_config(tmp_path) backups = config.paths.root / "backups" backups.mkdir(parents=True) stray = backups / "keep-me.txt" stray.write_bytes(b"x") result = delete_backup(config, "keep-me.txt", dry_run=False) assert result.ok is False assert stray.exists(), "a non-archive file must never be unlinked" def test_delete_backup_refuses_path_traversal(tmp_path: Path) -> None: config = _make_config(tmp_path) (config.paths.root / "backups").mkdir(parents=True) secret = config.paths.root / "secret.7z" secret.write_bytes(b"x") # A traversal that also happens to satisfy the name regex must still be # refused by containment (parent != backups dir) -- and the regex rejects # the slash anyway, so this is doubly safe. result = delete_backup(config, "../secret.7z", dry_run=False) assert result.ok is False assert secret.exists() def test_delete_backup_missing_file_is_not_ok(tmp_path: Path) -> None: config = _make_config(tmp_path) (config.paths.root / "backups").mkdir(parents=True) result = delete_backup(config, "Template_LinuxBuild2404_20260722_145001.7z", dry_run=False) assert result.ok is False assert result.error == "backup not found" # ── route: /artifacts/{id}/delete ─────────────────────────────────────────── def test_delete_route_without_csrf_is_403(tmp_path: Path) -> None: config = _make_config(tmp_path) _make_artifact(config) client = TestClient(create_app(config)) client.get("/files") # establish a session resp = client.post(f"/artifacts/{encode_artifact_id('build-42.tar.zst')}/delete", data={}) assert resp.status_code == 403 def test_delete_route_invalid_id_is_400(tmp_path: Path) -> None: config = _make_config(tmp_path) client = TestClient(create_app(config)) csrf = _get_csrf(client) # A path-bearing decoded name must be rejected before any op runs. resp = client.post(f"/artifacts/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf}) assert resp.status_code == 400 def test_delete_route_preview_confirms_and_keeps_file(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_artifact(config) client = TestClient(create_app(config)) csrf = _get_csrf(client) resp = client.post( f"/artifacts/{encode_artifact_id(path.name)}/delete", data={"csrf": csrf} ) assert resp.status_code == 200 assert "Confirm" in resp.text assert "would delete" in resp.text assert path.exists() assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run" def test_delete_route_execute_deletes_and_audits_ok(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_artifact(config) client = TestClient(create_app(config)) csrf = _get_csrf(client) resp = client.post( f"/artifacts/{encode_artifact_id(path.name)}/delete", data={"csrf": csrf, "stage": "execute"}, ) assert resp.status_code == 200 assert not path.exists() entry = read_audit(config.web.audit_log, last=10)[-1] assert entry.action == "artifact.delete" assert entry.result == "ok" # ── route: /backups/{id}/delete ───────────────────────────────────────────── def test_backup_delete_without_csrf_is_403(tmp_path: Path) -> None: config = _make_config(tmp_path) _make_backup(config) client = TestClient(create_app(config)) client.get("/") # establish a session resp = client.post( f"/backups/{encode_artifact_id('Template_LinuxBuild2404_20260722_145001.7z')}/delete", data={}, ) assert resp.status_code == 403 def test_backup_delete_invalid_id_is_400(tmp_path: Path) -> None: config = _make_config(tmp_path) client = TestClient(create_app(config)) csrf = _get_csrf(client) resp = client.post(f"/backups/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf}) assert resp.status_code == 400 def test_backup_delete_preview_then_execute_full_cycle(tmp_path: Path) -> None: config = _make_config(tmp_path) path = _make_backup(config) client = TestClient(create_app(config)) csrf = _get_csrf(client) backup_id = encode_artifact_id(path.name) preview = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf}) assert preview.status_code == 200 assert "Confirm" in preview.text assert "would delete backup" in preview.text assert path.exists() assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run" execute = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf, "stage": "execute"}) assert execute.status_code == 200 assert not path.exists() # Every terminal result asks the dashboard to refresh (#3). assert execute.headers["HX-Trigger"] == "ci:refresh" entry = read_audit(config.web.audit_log, last=10)[-1] assert entry.action == "backup.delete" assert entry.result == "ok"