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.
164 lines
5.6 KiB
Python
164 lines
5.6 KiB
Python
"""Tests for the A4 theming fix.
|
|
|
|
Root cause being fixed: mutating ``<form method="post">`` do a full-page
|
|
navigation, and the POST routes used to return a bare partial with no
|
|
``base.html`` wrapper -- an unstyled page. Now the same route renders
|
|
inline (bare partial) for an htmx request (``HX-Request: true``) and a
|
|
themed full page (``templates/action_page.html``, extending ``base.html``)
|
|
for a plain no-JS navigation.
|
|
|
|
Config/CSRF setup mirrors ``test_web_actions.py``'s ``_make_config`` (a bare,
|
|
non-OIDC config is enough -- these routes only need a CSRF-bearing session,
|
|
not real authentication).
|
|
"""
|
|
|
|
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.web.app import create_app, get_backend
|
|
|
|
|
|
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
|
|
),
|
|
# audit_log MUST live under tmp_path -- the WebConfig default points
|
|
# at the real host's /var/lib/ci/logs, which these tests must never
|
|
# touch (see the module docstring: routes exercised here audit).
|
|
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)
|
|
|
|
|
|
# ── HX-Request present -> bare partial, no theme wrapper ───────────────────
|
|
|
|
|
|
def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
|
|
app = create_app(_make_config(tmp_path))
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post(
|
|
"/cache/purge", data={"csrf": csrf}, headers={"HX-Request": "true"}
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert "<html" not in resp.text.lower()
|
|
assert '<link rel="stylesheet"' not in resp.text
|
|
assert "Confirm" in resp.text
|
|
|
|
|
|
def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
|
|
app = create_app(_make_config(tmp_path))
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post(
|
|
"/cache/purge",
|
|
data={"csrf": csrf, "stage": "execute"},
|
|
headers={"HX-Request": "true"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert "<html" not in resp.text.lower()
|
|
assert '<link rel="stylesheet"' not in resp.text
|
|
|
|
|
|
# ── HX-Request absent -> full themed page ───────────────────────────────────
|
|
|
|
|
|
def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
|
|
app = create_app(_make_config(tmp_path))
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post("/cache/purge", data={"csrf": csrf})
|
|
|
|
assert resp.status_code == 200
|
|
assert '<link rel="stylesheet" href="/static/app.css?v=' in resp.text
|
|
assert 'id="action-slot"' in resp.text
|
|
assert "Confirm" in resp.text
|
|
|
|
|
|
def test_no_hx_header_execute_gets_full_themed_page(tmp_path: Path) -> None:
|
|
app = create_app(_make_config(tmp_path))
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
|
|
|
|
assert resp.status_code == 200
|
|
assert '<link rel="stylesheet"' in resp.text
|
|
assert 'id="action-slot"' in resp.text
|
|
|
|
|
|
# ── wrapper never changes status code, CSRF, or audit behavior ────────────
|
|
|
|
|
|
def test_lock_conflict_status_code_preserved_in_full_page(tmp_path: Path) -> None:
|
|
config = _make_config(tmp_path)
|
|
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
|
|
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")
|
|
app = create_app(config)
|
|
app.dependency_overrides[get_backend] = lambda: None
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
|
|
|
|
assert resp.status_code == 409
|
|
assert '<link rel="stylesheet"' in resp.text
|
|
assert 'id="action-slot"' in resp.text
|
|
|
|
|
|
def test_lock_conflict_status_code_preserved_bare_for_htmx(tmp_path: Path) -> None:
|
|
config = _make_config(tmp_path)
|
|
config.paths.build_vms.mkdir(parents=True, exist_ok=True)
|
|
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")
|
|
app = create_app(config)
|
|
app.dependency_overrides[get_backend] = lambda: None
|
|
client = TestClient(app)
|
|
csrf = _csrf_from(client.get("/").text)
|
|
|
|
resp = client.post(
|
|
"/vms/cleanup",
|
|
data={"csrf": csrf, "stage": "execute"},
|
|
headers={"HX-Request": "true"},
|
|
)
|
|
|
|
assert resp.status_code == 409
|
|
assert "<html" not in resp.text.lower()
|
|
|
|
|
|
def test_missing_csrf_still_403s_regardless_of_hx_header(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={}, headers={"HX-Request": "true"})
|
|
|
|
assert resp.status_code == 403
|