feat(web): dashboard redesign — Gitea palette, health panels, VM/audit enrichment (stages A–C)
Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve). Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py. Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter. Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""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("/cache").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("/cache").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("/cache").text)
|
||||
|
||||
resp = client.post("/cache/purge", data={"csrf": csrf})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert '<link rel="stylesheet" href="/static/app.css">' 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("/cache").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("/cache").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("/cache").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
|
||||
Reference in New Issue
Block a user