feat(3 U4): YunoHost-style dashboard grid — per-recipe level badge + status + version + app screenshot thumbnail + per-recipe /recipe/<name> history; reads results.json artifacts (R5); 9 dashboard unit tests
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@ -50,6 +50,9 @@ def _read(path):
|
||||
DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"])
|
||||
|
||||
_CACHE = {"ts": 0.0, "recipes": []}
|
||||
# Raw custom builds (newest-first), cached so the overview AND the per-recipe history page share one
|
||||
# Drone fetch within CACHE_TTL (U4 history reads the same list latest_per_recipe groups from).
|
||||
_BUILDS = {"ts": 0.0, "builds": []}
|
||||
|
||||
_COLORS = {
|
||||
"success": "#3fb950",
|
||||
@ -60,11 +63,42 @@ _COLORS = {
|
||||
"killed": "#8b949e",
|
||||
}
|
||||
|
||||
# Level → colour ramp, kept in sync with runner/harness/card.py LEVEL_COLOR (the dashboard is a
|
||||
# standalone stdlib service that doesn't import the runner harness, so the small map is duplicated).
|
||||
_LEVEL_COLOR = {
|
||||
0: "#e5534b", 1: "#e0823d", 2: "#e0823d", 3: "#d9b343",
|
||||
4: "#a0b93f", 5: "#57ab5a", 6: "#3fb950",
|
||||
}
|
||||
|
||||
|
||||
def level_color(level):
|
||||
try:
|
||||
return _LEVEL_COLOR.get(int(level), "#8b949e")
|
||||
except (TypeError, ValueError):
|
||||
return "#8b949e"
|
||||
|
||||
|
||||
def log(*a):
|
||||
print(*a, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _results_for(number):
|
||||
"""Read a run's results.json from the bind-mounted runs dir (R5: the grid surfaces the real
|
||||
level/version/screenshot/flags from the artifact, not just Drone's pass/fail). Traversal-guarded
|
||||
like serve_run_file; returns {} on any miss so the overview degrades to Drone-only fields."""
|
||||
if number in (None, ""):
|
||||
return {}
|
||||
base = os.path.realpath(CCCI_RUNS_DIR)
|
||||
real = os.path.realpath(os.path.join(base, str(number), "results.json"))
|
||||
if not real.startswith(base + os.sep):
|
||||
return {}
|
||||
try:
|
||||
with open(real) as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _drone(path):
|
||||
req = urllib.request.Request(
|
||||
f"{DRONE_URL}{path}", headers={"Authorization": f"Bearer {DRONE_TOKEN}"}
|
||||
@ -73,40 +107,74 @@ def _drone(path):
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def latest_per_recipe():
|
||||
"""Latest recipe-CI build per recipe (event=custom builds carry the RECIPE param)."""
|
||||
def _custom_recipe_builds():
|
||||
"""All event=custom recipe-CI builds (newest first), each carrying a real RECIPE param. The
|
||||
cc-ci repo's own name isn't a recipe under test (e.g. an Adversary `!testme` on the cc-ci PR) so
|
||||
it's filtered out. Cached (CACHE_TTL) and shared by the overview + history. None on fetch error."""
|
||||
now = time.time()
|
||||
if now - _BUILDS["ts"] <= CACHE_TTL and _BUILDS["builds"]:
|
||||
return _BUILDS["builds"]
|
||||
try:
|
||||
builds = _drone(f"/api/repos/{CI_REPO}/builds?per_page=100")
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
log("drone fetch failed", e)
|
||||
return None
|
||||
latest = {}
|
||||
own = CI_REPO.rsplit("/", 1)[-1]
|
||||
out = []
|
||||
for b in builds or []:
|
||||
if b.get("event") != "custom":
|
||||
continue
|
||||
recipe = (b.get("params") or {}).get("RECIPE")
|
||||
if not recipe:
|
||||
if not recipe or recipe == own:
|
||||
continue
|
||||
# The cc-ci repo's own name isn't a recipe under test (e.g. an Adversary !testme on the
|
||||
# cc-ci PR); don't list it as a recipe row.
|
||||
if recipe == CI_REPO.rsplit("/", 1)[-1]:
|
||||
continue
|
||||
if recipe not in latest or b.get("number", 0) > latest[recipe].get("number", 0):
|
||||
out.append(b)
|
||||
out.sort(key=lambda b: b.get("number", 0), reverse=True)
|
||||
_BUILDS["builds"] = out
|
||||
_BUILDS["ts"] = now
|
||||
return out
|
||||
|
||||
|
||||
def _build_row(b):
|
||||
"""Project a Drone build (+ its results.json artifact, if present) into a display row. The level/
|
||||
version/screenshot/flags come from the run's results.json so the grid mirrors the real artifact
|
||||
(R5/cardinal: never greener than the run); they're absent until U0+ artifacts exist for a run."""
|
||||
ref = (b.get("params") or {}).get("REF") or ""
|
||||
res = _results_for(b.get("number"))
|
||||
return {
|
||||
"recipe": (b.get("params") or {}).get("RECIPE"),
|
||||
"status": b.get("status", "unknown"),
|
||||
"number": b.get("number"),
|
||||
"ref": ref[:8],
|
||||
"version": res.get("version") or ref[:12] or "—",
|
||||
"level": res.get("level"),
|
||||
"level_cap_reason": res.get("level_cap_reason") or "",
|
||||
"has_screenshot": bool(res.get("screenshot")),
|
||||
"flags": res.get("flags") or {},
|
||||
"finished": b.get("finished") or 0,
|
||||
"url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
|
||||
}
|
||||
|
||||
|
||||
def latest_per_recipe():
|
||||
"""Latest recipe-CI build per recipe, augmented from results.json (R5). None on fetch error."""
|
||||
builds = _custom_recipe_builds()
|
||||
if builds is None:
|
||||
return None
|
||||
latest = {}
|
||||
for b in builds: # newest-first → first seen per recipe is the latest
|
||||
recipe = (b.get("params") or {}).get("RECIPE")
|
||||
if recipe not in latest:
|
||||
latest[recipe] = b
|
||||
rows = []
|
||||
for recipe, b in sorted(latest.items()):
|
||||
ref = (b.get("params") or {}).get("REF") or ""
|
||||
rows.append(
|
||||
{
|
||||
"recipe": recipe,
|
||||
"status": b.get("status", "unknown"),
|
||||
"number": b.get("number"),
|
||||
"ref": ref[:8],
|
||||
"finished": b.get("finished") or 0,
|
||||
"url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
return [_build_row(latest[r]) for r in sorted(latest)]
|
||||
|
||||
|
||||
def history_for(recipe):
|
||||
"""All runs for one recipe (newest first), augmented from results.json — the per-recipe history
|
||||
page (R5 'link to history'). [] if none / None on fetch error."""
|
||||
builds = _custom_recipe_builds()
|
||||
if builds is None:
|
||||
return None
|
||||
return [_build_row(b) for b in builds if (b.get("params") or {}).get("RECIPE") == recipe]
|
||||
|
||||
|
||||
def recipes_cached():
|
||||
@ -132,35 +200,130 @@ def _ago(ts):
|
||||
return f"{d // 86400}d ago"
|
||||
|
||||
|
||||
_PAGE_CSS = """
|
||||
body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;margin:0;padding:0}
|
||||
.wrap{max-width:1100px;margin:0 auto;padding:1.5rem 1rem 3rem}
|
||||
h1{font-size:1.5rem;margin:.2rem 0;display:flex;align-items:center;gap:.5rem}
|
||||
a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline}
|
||||
.sub{color:#8b949e;font-size:.9rem;margin:.3rem 0 1.2rem}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem}
|
||||
.card{background:#161b22;border:1px solid #21262d;border-radius:.6rem;overflow:hidden;display:flex;flex-direction:column}
|
||||
.shot{position:relative;display:block;height:140px;background:#0d1117 center/cover no-repeat;border-bottom:1px solid #21262d}
|
||||
.shot .ph{display:flex;height:100%;align-items:center;justify-content:center;color:#484f58;font-size:.8rem}
|
||||
.lvl{position:absolute;top:.5rem;right:.5rem;color:#fff;font-weight:700;font-size:.8rem;padding:.15rem .5rem;border-radius:.5rem;box-shadow:0 1px 3px #0008}
|
||||
.body{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.4rem;flex:1}
|
||||
.name{font-weight:700;font-size:1.05rem;color:#e6edf3}
|
||||
.row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.82rem}
|
||||
.pill{color:#fff;padding:.08rem .5rem;border-radius:.5rem;font-size:.75rem;font-weight:600}
|
||||
.cap{color:#8b949e;font-size:.75rem}
|
||||
code{background:#0d1117;border:1px solid #21262d;border-radius:.3rem;padding:0 .3rem;font-size:.78rem;color:#c9d1d9}
|
||||
.flags{display:flex;gap:.4rem;font-size:.72rem;color:#8b949e}
|
||||
.foot{margin-top:auto;display:flex;justify-content:space-between;font-size:.8rem;padding-top:.3rem;border-top:1px solid #21262d}
|
||||
table{border-collapse:collapse;width:100%;margin-top:1rem}
|
||||
th,td{text-align:left;padding:.5rem .7rem;border-bottom:1px solid #21262d;font-size:.88rem}
|
||||
th{color:#8b949e;font-weight:600;font-size:.8rem;text-transform:uppercase}
|
||||
.flower{flex:0 0 auto}
|
||||
"""
|
||||
|
||||
# Inline sunflower (matches the summary card; no emoji font dependency in the page header).
|
||||
_FLOWER = (
|
||||
'<svg class="flower" width="26" height="26" viewBox="0 0 28 28">'
|
||||
'<g fill="#f0b429">'
|
||||
+ "".join(
|
||||
f'<ellipse cx="14" cy="5.5" rx="2.6" ry="5.5" transform="rotate({a} 14 14)"/>'
|
||||
for a in range(0, 360, 45)
|
||||
)
|
||||
+ '</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
|
||||
)
|
||||
|
||||
|
||||
def _level_pill(level):
|
||||
"""The big corner LEVEL badge (R5). '—' (grey) when no results.json level yet."""
|
||||
if level is None:
|
||||
return '<span class="lvl" style="background:#8b949e">level —</span>'
|
||||
return f'<span class="lvl" style="background:{level_color(level)}">level {int(level)}</span>'
|
||||
|
||||
|
||||
def _flags_html(flags):
|
||||
out = []
|
||||
if flags.get("clean_teardown"):
|
||||
out.append('<span title="clean teardown">✔ teardown</span>')
|
||||
if flags.get("no_secret_leak"):
|
||||
out.append('<span title="no secret leak">✔ no-leak</span>')
|
||||
return f'<div class="flags">{"".join(out)}</div>' if out else ""
|
||||
|
||||
|
||||
def _card(r):
|
||||
color = _COLORS.get(r["status"], "#8b949e")
|
||||
num = r["number"]
|
||||
run_url = html.escape(r["url"])
|
||||
# Screenshot thumbnail (clickable → full summary card). Placeholder when no screenshot captured.
|
||||
if r["has_screenshot"]:
|
||||
shot = (
|
||||
f'<a class="shot" href="/runs/{num}/summary.png" '
|
||||
f'style="background-image:url(/runs/{num}/screenshot.png)" '
|
||||
f'title="view summary card"><span>{_level_pill(r["level"])}</span></a>'
|
||||
)
|
||||
else:
|
||||
shot = (
|
||||
f'<a class="shot" href="{run_url}" title="open run">'
|
||||
f'<span class="ph">no screenshot</span>{_level_pill(r["level"])}</a>'
|
||||
)
|
||||
cap = f'<div class="cap">{html.escape(r["level_cap_reason"])}</div>' if r["level_cap_reason"] else ""
|
||||
return (
|
||||
f'<div class="card">{shot}<div class="body">'
|
||||
f'<div class="name">{html.escape(r["recipe"])}</div>'
|
||||
f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>'
|
||||
f'<code>{html.escape(r["version"])}</code></div>'
|
||||
f"{cap}{_flags_html(r['flags'])}"
|
||||
f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>'
|
||||
f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>'
|
||||
f"</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _page(title, inner):
|
||||
return (
|
||||
f'<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>'
|
||||
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
f'<meta http-equiv="refresh" content="30"><style>{_PAGE_CSS}</style></head>'
|
||||
f'<body><div class="wrap">{inner}</div></body></html>'
|
||||
)
|
||||
|
||||
|
||||
def render_overview(rows):
|
||||
cards = "\n".join(_card(r) for r in rows) or '<p class="sub">no recipe runs yet</p>'
|
||||
inner = (
|
||||
f"<h1>{_FLOWER} cc-ci — Co-op Cloud recipe CI</h1>"
|
||||
'<p class="sub">Latest <code>!testme</code> run per enrolled recipe — level, status, version, '
|
||||
"app screenshot. Click a card for its summary card; “history” for past runs. "
|
||||
"Auto-refreshes every 30s.</p>"
|
||||
f'<div class="grid">{cards}</div>'
|
||||
)
|
||||
return _page("cc-ci — Co-op Cloud recipe CI", inner)
|
||||
|
||||
|
||||
def render_history(recipe, rows):
|
||||
trs = []
|
||||
for r in rows:
|
||||
color = _COLORS.get(r["status"], "#8b949e")
|
||||
lvl = "—" if r["level"] is None else f'<b style="color:{level_color(r["level"])}">L{int(r["level"])}</b>'
|
||||
shot = f'<a href="/runs/{r["number"]}/summary.png">card</a>' if r["has_screenshot"] else "—"
|
||||
trs.append(
|
||||
f'<tr><td><b>{html.escape(r["recipe"])}</b></td>'
|
||||
f'<td><span class="badge" style="background:{color}">{html.escape(r["status"])}</span></td>'
|
||||
f'<td><code>{html.escape(r["ref"]) or "—"}</code></td>'
|
||||
f'<td>{_ago(r["finished"])}</td>'
|
||||
f'<td><a href="{html.escape(r["url"])}">run #{r["number"]}</a></td></tr>'
|
||||
f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>'
|
||||
f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>'
|
||||
f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>"
|
||||
f'<td>{_ago(r["finished"])}</td><td>{shot}</td></tr>'
|
||||
)
|
||||
body = "\n".join(trs) or '<tr><td colspan="5">no recipe runs yet</td></tr>'
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8">
|
||||
<title>cc-ci — Co-op Cloud recipe CI</title>
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#0d1117;color:#c9d1d9;margin:2rem auto;max-width:900px;padding:0 1rem}}
|
||||
h1{{font-size:1.4rem}} a{{color:#58a6ff}} table{{border-collapse:collapse;width:100%;margin-top:1rem}}
|
||||
th,td{{text-align:left;padding:.5rem .75rem;border-bottom:1px solid #21262d}}
|
||||
th{{color:#8b949e;font-weight:600;font-size:.85rem;text-transform:uppercase}}
|
||||
.badge{{color:#fff;padding:.1rem .5rem;border-radius:.5rem;font-size:.8rem;font-weight:600}}
|
||||
.sub{{color:#8b949e;font-size:.85rem}}
|
||||
</style></head><body>
|
||||
<h1>cc-ci — Co-op Cloud recipe CI</h1>
|
||||
<p class="sub">Latest <code>!testme</code> run per enrolled recipe. Per-run logs live in Drone.
|
||||
Auto-refreshes every 30s.</p>
|
||||
<table><thead><tr><th>Recipe</th><th>Status</th><th>Ref</th><th>Last run</th><th>Run</th></tr></thead>
|
||||
<tbody>{body}</tbody></table>
|
||||
</body></html>"""
|
||||
body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>'
|
||||
inner = (
|
||||
f'<h1>{_FLOWER} {html.escape(recipe)} — run history</h1>'
|
||||
'<p class="sub"><a href="/">← all recipes</a> · every <code>!testme</code> run, newest first.</p>'
|
||||
"<table><thead><tr><th>Run</th><th>Status</th><th>Level</th><th>Version</th>"
|
||||
"<th>When</th><th>Card</th></tr></thead><tbody>"
|
||||
f"{body}</tbody></table>"
|
||||
)
|
||||
return _page(f"{recipe} — cc-ci history", inner)
|
||||
|
||||
|
||||
def render_badge(recipe, status):
|
||||
@ -211,6 +374,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if got is not None:
|
||||
return 200, got[1], got[0]
|
||||
return 404, "not found", "text/plain"
|
||||
if path.startswith("/recipe/"):
|
||||
recipe = path[len("/recipe/") :]
|
||||
if _RUN_ID_RE.match(recipe):
|
||||
rows = history_for(recipe) or []
|
||||
return 200, render_history(recipe, rows), "text/html; charset=utf-8"
|
||||
return 404, "not found", "text/plain"
|
||||
if path == "/":
|
||||
return 200, render_overview(recipes_cached()), "text/html; charset=utf-8"
|
||||
return 404, "not found", "text/plain"
|
||||
|
||||
124
tests/unit/test_dashboard.py
Normal file
124
tests/unit/test_dashboard.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Phase 3 U4 — dashboard YunoHost-style grid + per-recipe history (pure-render + helpers).
|
||||
|
||||
The dashboard reads a Drone admin token at import; point DRONE_TOKEN_FILE at a temp file so the
|
||||
module imports without the real secret. All tests here are pure (no network): they exercise the
|
||||
rendering + results.json projection, asserting the grid/history mirror the artifact and never present
|
||||
a run greener than its data (R5 / cardinal guardrail)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
_tok = tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok")
|
||||
_tok.write("test-token")
|
||||
_tok.close()
|
||||
os.environ["DRONE_TOKEN_FILE"] = _tok.name
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "dashboard"))
|
||||
|
||||
import dashboard # noqa: E402
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
base = {
|
||||
"recipe": "custom-html", "status": "success", "number": 4, "ref": "db9a9502",
|
||||
"version": "db9a95024e9d", "level": 4, "level_cap_reason": "L5 integration N/A",
|
||||
"has_screenshot": True, "flags": {"clean_teardown": True, "no_secret_leak": True},
|
||||
"finished": 0, "url": "https://drone.x/cc-ci/4",
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_level_color_ramp_and_fallback():
|
||||
assert dashboard.level_color(0) == "#e5534b"
|
||||
assert dashboard.level_color(6) == "#3fb950"
|
||||
assert dashboard.level_color(4) == "#a0b93f"
|
||||
assert dashboard.level_color(99) == "#8b949e"
|
||||
assert dashboard.level_color(None) == "#8b949e"
|
||||
|
||||
|
||||
def test_overview_grid_mirrors_results():
|
||||
out = dashboard.render_overview([_row()])
|
||||
assert "custom-html" in out
|
||||
assert "level 4" in out # the corner level pill
|
||||
assert dashboard.level_color(4) in out # coloured by level
|
||||
assert "db9a95024e9d" in out # version from results.json
|
||||
assert "/runs/4/screenshot.png" in out # thumbnail
|
||||
assert "/runs/4/summary.png" in out # links to full card
|
||||
assert "/recipe/custom-html" in out # history link
|
||||
assert "✔ teardown" in out and "✔ no-leak" in out
|
||||
|
||||
|
||||
def test_overview_never_greener_than_data():
|
||||
# A failed run at level 0 must show level 0 + the failure pill — never a green/high level.
|
||||
out = dashboard.render_overview([_row(status="failure", level=0, has_screenshot=False,
|
||||
flags={}, level_cap_reason="L1 install FAILED")])
|
||||
assert "level 0" in out
|
||||
assert dashboard.level_color(0) in out # red
|
||||
assert dashboard._COLORS["failure"] in out
|
||||
assert "level 4" not in out and "level 5" not in out and "level 6" not in out
|
||||
assert "no screenshot" in out # placeholder, no broken image
|
||||
|
||||
|
||||
def test_level_pill_unknown_when_no_results():
|
||||
assert "level —" in dashboard._level_pill(None)
|
||||
assert "#8b949e" in dashboard._level_pill(None)
|
||||
|
||||
|
||||
def test_history_table_lists_runs():
|
||||
out = dashboard.render_history("custom-html", [_row(number=4), _row(number=3, level=2)])
|
||||
assert "custom-html — run history" in out
|
||||
assert "#4" in out and "#3" in out
|
||||
assert "L4" in out and "L2" in out
|
||||
assert "← all recipes" in out
|
||||
assert "/runs/4/summary.png" in out # per-run card link
|
||||
|
||||
|
||||
def test_history_empty():
|
||||
out = dashboard.render_history("hedgedoc", [])
|
||||
assert "no runs for this recipe yet" in out
|
||||
|
||||
|
||||
def test_build_row_projects_results(monkeypatch):
|
||||
monkeypatch.setattr(dashboard, "_results_for", lambda n: {
|
||||
"version": "1.2.3", "level": 2, "level_cap_reason": "cap",
|
||||
"screenshot": "screenshot.png", "flags": {"clean_teardown": True},
|
||||
})
|
||||
b = {"number": 7, "status": "success", "event": "custom",
|
||||
"params": {"RECIPE": "n8n", "REF": "abcdef1234567890"}, "finished": 10}
|
||||
r = dashboard._build_row(b)
|
||||
assert r["recipe"] == "n8n" and r["number"] == 7
|
||||
assert r["level"] == 2 and r["version"] == "1.2.3"
|
||||
assert r["has_screenshot"] is True
|
||||
assert r["url"].endswith("/cc-ci/7")
|
||||
|
||||
|
||||
def test_build_row_degrades_without_results(monkeypatch):
|
||||
# No results.json (e.g. an old run): grid still renders from Drone fields, level absent.
|
||||
monkeypatch.setattr(dashboard, "_results_for", lambda n: {})
|
||||
b = {"number": 9, "status": "running", "event": "custom",
|
||||
"params": {"RECIPE": "ghost", "REF": "deadbeefcafe1234567890"}, "finished": 0}
|
||||
r = dashboard._build_row(b)
|
||||
assert r["level"] is None and r["has_screenshot"] is False
|
||||
assert r["version"] == "deadbeefcafe" # ref[:12] fallback
|
||||
# render must not crash or claim a level
|
||||
assert "level —" in dashboard.render_overview([r])
|
||||
|
||||
|
||||
def test_results_for_traversal_guarded():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.makedirs(os.path.join(d, "5"))
|
||||
with open(os.path.join(d, "5", "results.json"), "w") as f:
|
||||
json.dump({"level": 3}, f)
|
||||
orig = dashboard.CCCI_RUNS_DIR
|
||||
dashboard.CCCI_RUNS_DIR = d
|
||||
try:
|
||||
assert dashboard._results_for("5") == {"level": 3}
|
||||
assert dashboard._results_for("../../etc") == {} # traversal rejected
|
||||
assert dashboard._results_for("nonexist") == {} # missing → {}
|
||||
assert dashboard._results_for("") == {}
|
||||
finally:
|
||||
dashboard.CCCI_RUNS_DIR = orig
|
||||
Reference in New Issue
Block a user