Files
FabledSteward/tests/integration/test_prometheus_template_library.py
T
bvandeusenandClaude Opus 5.5 bd762307e0
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
feat(prometheus): the widget template library: list, edit with live preview, paste in, upload, export, duplicate (#5692)
Prometheus gains a "Widget templates" tab beside Targets. It lists the
built-in templates and an admin's own, any that no longer pass the checker
first. A template opens as its YAML beside a live preview drawn over a
chosen target exactly as the dashboard widget draws it (routes.draw, now
shared by both). An admin writes or pastes a new one, reads a .yaml upload
into the editor to look over, duplicates any template under a free name,
edits and deletes their own; anyone can export the YAML. Built-in
templates are read-only.

Custom templates are rows in prometheus_widget_templates (migration
prometheus_002_templates), kept in backups, keyed "custom:<id>" so a
rename keeps its widgets. Saving needs a template that passes the checker
and a name no other template has. Create, update and delete are audited
and link to the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:22:31 -04:00

169 lines
8.0 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration: the widget template library (#5692) against Postgres. An admin
copies a built-in template, saves it under its own name, edits, previews,
exports, uploads and deletes; the widget picker lists it; each change is
audited; a viewer can look but not change. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import io
import json
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role: str):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
name = f"tl-{uid[:8]}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token",
login_at=time.time(), tz="UTC")
return client, uid
async def _audits(app, uid: str) -> list[tuple[str, dict]]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def _prom(sub):
"""A submodule of the prometheus plugin as the app loaded it: importing
plugins.prometheus beside it would define its tables twice."""
import importlib
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("prometheus")
return importlib.import_module(f"{loaded.__name__ if loaded else 'plugins.prometheus'}.{sub}")
def _text(page: str) -> str:
return " ".join(page.split())
def test_an_admin_copies_edits_previews_exports_and_deletes_a_template(app):
from steward.core.widgets import WIDGET_REGISTRY
from steward.dashboard.routes import with_options
tag = uuid.uuid4().hex[:8]
mine = f"Archives {tag}"
async def go():
admin, uid = await _client(app, "admin")
out = {}
listing = _text(await (await admin.get("/plugins/prometheus/templates/")).get_data(as_text=True))
out["listed_bundled"] = "Fabled Archives" in listing and "built in" in listing
out["tab_from_targets"] = 'href="/plugins/prometheus/templates/"' in _text(
await (await admin.get("/plugins/prometheus/")).get_data(as_text=True))
# Duplicate: an editor holding a copy under a name nobody has, nothing saved yet.
copy = await (await admin.get("/plugins/prometheus/templates/new?from=bundled:fabled_archives")
).get_data(as_text=True)
out["copy_named"] = "Fabled Archives (copy" in copy and "A copy of “Fabled Archives”" in copy
library = _prom("library")
text = library.renamed(library.bundled()[0].text, mine)
# A template with a problem isn't saved, and says where.
bad = await admin.post("/plugins/prometheus/templates/new", headers=CSRF,
form={"text": text.replace("version: 1", "version: one")})
out["bad"] = (bad.status_code, "Not saved." in _text(await bad.get_data(as_text=True)))
# The bundled template's name is taken.
clash = await admin.post("/plugins/prometheus/templates/new", headers=CSRF,
form={"text": library.bundled()[0].text})
out["clash"] = (clash.status_code, "is already named" in await clash.get_data(as_text=True))
saved = await admin.post("/plugins/prometheus/templates/new", headers=CSRF, form={"text": text})
out["saved"] = saved.status_code
where = saved.headers["Location"]
key = where.rsplit("/", 1)[1]
out["key"] = key
async with app.db_sessionmaker() as db:
picker = await with_options(db, WIDGET_REGISTRY["prometheus"])
out["picker"] = {"value": key, "label": mine} in picker["params"][1]["options"]
page = _text(await (await admin.get(where)).get_data(as_text=True))
out["editable"] = f'action="/plugins/prometheus/templates/{key}/edit"' in page and "Your own" in page
renamed = library.renamed(text, f"{mine} v2").replace("version: 1", "version: 2")
edit = await admin.post(f"/plugins/prometheus/templates/{key}/edit", headers=CSRF, form={"text": renamed})
out["edited"] = edit.status_code
exported = await admin.get(f"/plugins/prometheus/templates/{key}/export")
out["export"] = (exported.status_code, exported.headers.get("Content-Disposition"),
await exported.get_data(as_text=True) == renamed)
preview = await admin.post("/plugins/prometheus/templates/preview", headers=CSRF,
form={"text": "name: x\n"})
out["preview_bad"] = "to fix before it draws" in await preview.get_data(as_text=True)
preview = await admin.post("/plugins/prometheus/templates/preview", headers=CSRF,
form={"text": renamed, "target": "no-such-target"})
out["preview_ok"] = "passes the checker" in await preview.get_data(as_text=True)
from werkzeug.datastructures import FileStorage
upload = await admin.post("/plugins/prometheus/templates/upload", headers=CSRF, files={
"file": FileStorage(io.BytesIO(("" + renamed).encode()), filename="mine.yaml")})
up = await upload.get_data(as_text=True)
out["upload"] = (upload.status_code, "Read from mine.yaml" in up, f"{mine} v2" in up)
gone = await admin.post(f"/plugins/prometheus/templates/{key}/delete", headers=CSRF)
out["deleted"] = gone.status_code
out["after"] = (await admin.get(where)).status_code
out["audits"] = [(a, d.get("name"), d.get("old_name")) for a, d in await _audits(app, uid)]
out["mine"] = mine
return out
out = asyncio.run(go())
mine = out["mine"]
assert out["listed_bundled"] and out["tab_from_targets"] and out["copy_named"]
assert out["bad"] == (422, True) and out["clash"] == (422, True)
assert out["saved"] == 302 and out["key"].startswith("custom:")
assert out["picker"] and out["editable"] and out["edited"] == 302
assert out["export"] == (200, f'attachment; filename="archives-{mine.split()[1]}-v2.yaml"', True)
assert out["preview_bad"] and out["preview_ok"]
assert out["upload"] == (200, True, True)
assert out["deleted"] == 302 and out["after"] == 404
assert out["audits"] == [("prometheus.template_created", mine, None),
("prometheus.template_updated", f"{mine} v2", mine),
("prometheus.template_deleted", f"{mine} v2", None)]
def test_a_viewer_looks_and_exports_but_changes_nothing(app):
async def go():
viewer, _ = await _client(app, "viewer")
listing = await viewer.get("/plugins/prometheus/templates/")
page = await viewer.get("/plugins/prometheus/templates/bundled:fabled_archives")
exported = await viewer.get("/plugins/prometheus/templates/bundled:fabled_archives/export")
new = await viewer.get("/plugins/prometheus/templates/new")
post = await viewer.post("/plugins/prometheus/templates/new", headers=CSRF, form={"text": "x"})
return (listing.status_code, page.status_code, " readonly" in await page.get_data(as_text=True),
exported.status_code, new.status_code, post.status_code)
listing, page, readonly, exported, new, post = asyncio.run(go())
assert (listing, page, readonly, exported) == (200, 200, True, 200)
assert new == 403 and post == 403