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.
331 lines
11 KiB
Python
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"
|