Files
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

331 lines
11 KiB
Python

"""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"