Files
FabledSteward/tests/core/test_tickets.py
T
bvandeusenandClaude Opus 5.5 2a561210c3
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s
feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.

An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.

MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).

Tickets are left out of backups, like the timeline (2026-10-15 ruling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:06:43 -04:00

122 lines
6.9 KiB
Python

"""Tickets (#6351, #6352): what a ticket must say before it is opened, how
its steps reach the timeline and the audit log, and the pages that show it.
Opening, linking, resolving and "known issue" run against Postgres in
tests/integration/test_tickets.py."""
from datetime import datetime, timezone
from types import SimpleNamespace as NS
import pytest
from quart import render_template
from steward.core.formatting import status_info
from steward.mcp.tools.tickets import _hosts, _links
from steward.tickets import service as tk
from steward.timeline import catalog
from steward.timeline.config import Config
NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
AREAS = [list(a) for a in catalog.DEFAULT_AREAS]
def _ticket(**kw):
base = dict(id=12, title="Backups time out at night", body="Since the 20th.", area="storage",
hosts=["nas-1"], subject=None, status="open", opened_at=NOW, opened_by="bryan",
resolved_at=None, resolved_by=None, resolution=None, updated_at=NOW)
return NS(**{**base, **kw})
async def test_a_ticket_is_checked_before_it_is_opened():
cfg = Config(areas=AREAS)
f = await tk.fields(None, cfg, title=" Backups time out ", area="storage", body=" ", subject=" nas-1/restic ")
assert f == {"title": "Backups time out", "area": "storage", "hosts": [], "body": None,
"subject": "nas-1/restic"}
for kw, said in ((dict(title=" ", area="storage"), "Give the ticket a title"),
(dict(title="x" * 201, area="storage"), "200 characters"),
(dict(title="x", area="plumbing"), "Pick an area: Service, Host OS"),
(dict(title="x", area="storage", body="b" * 8001), "8000 characters")):
with pytest.raises(ValueError, match=said):
await tk.fields(None, cfg, **kw)
async def test_a_ticket_links_only_to_an_alert_rule_or_an_anomaly():
with pytest.raises(ValueError, match="not 'monitor'"):
await tk.check_links(None, [("monitor", "m1")])
def test_opening_resolving_and_reopening_go_on_the_timeline_and_the_audit_rows_stay_off():
for type_ in ("ticket.opened", "ticket.resolved", "ticket.reopened"):
t = catalog.TYPES[type_]
assert (t.kind, t.shown) == ("ticket", True), type_
for action in ("ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"):
assert catalog.audit_type(action) is None, action
def test_a_ticket_s_states_read_as_words():
assert status_info("open") == ("warn", "Open")
assert status_info("resolved") == ("ok", "Resolved")
assert status_info("known_issue") == ("info", "Known issue")
def test_the_mcp_tools_take_hosts_and_ids_as_a_list_or_a_comma_list():
assert _hosts("web-1, ,db-1 ") == ["web-1", "db-1"]
assert _hosts(None) == [] and _hosts(["web-1", ""]) == ["web-1"]
assert _links("r1, r2", ["a1"]) == [("alert_rule", "r1"), ("alert_rule", "r2"), ("anomaly", "a1")]
assert _links(None, None) == []
# ── Pages ───────────────────────────────────────────────────────────────────
async def test_the_list_shows_where_each_ticket_is_and_who_opened_it(app):
tickets = [_ticket(), _ticket(id=9, title="Fan noisy", area="hardware", hosts=[], subject="rack fan",
status="resolved", resolved_by="sam", resolved_at=NOW)]
async with app.test_request_context("/tickets/"):
from quart import session
session["user_role"] = "viewer"
page = await render_template("tickets/list.html", tickets=tickets, counts={12: 2}, status="all",
host="", open_count=1, labels=dict(AREAS))
assert '<a href="/tickets/12"><span class="tone-dim">#12</span> Backups time out at night</a>' in page
assert "2 comments" in page and ">Storage<" in page and "rack fan" in page
assert '<a href="/tickets/?host=nas-1&amp;status=all">nas-1</a>' in page
assert "1 open" in page and "sam, " in page
assert "Open a ticket" not in page # a viewer
async def test_an_operator_comments_and_resolves_and_a_viewer_only_reads(app):
log = [NS(at=NOW, kind="opened", actor="bryan", body=None),
NS(at=NOW, kind="edited", actor="bryan", body="title, linked alert rule"),
NS(at=NOW, kind="comment", actor="sam", body="restic prune runs long")]
links = [{"type": "alert_rule", "id": "r1", "label": "Alert rule Backup late", "url": "/alerts/history?rule=r1",
"gone": False},
{"type": "anomaly", "id": "a1", "label": "An anomaly no longer kept", "url": None, "gone": True}]
async with app.test_request_context("/tickets/12"):
from quart import session
session["user_role"] = "operator"
mine = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
resolved = await render_template("tickets/ticket.html", t=_ticket(status="resolved", resolved_by="sam",
resolved_at=NOW, resolution="Moved prune to Sunday"),
log=log, links=[], area_label="Storage")
session["user_role"] = "viewer"
viewer = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
assert '<a href="/alerts/history?rule=r1">Alert rule Backup late</a>' in mine
assert '<span class="tone-dim">An anomaly no longer kept</span>' in mine
assert "edited: title, linked alert rule" in mine and "restic prune runs long" in mine
assert 'action="/tickets/12/comment"' in mine and 'formaction="/tickets/12/resolve"' in mine
assert 'formaction="/tickets/12/reopen"' in resolved and "Moved prune to Sunday" in resolved
assert "/tickets/12/comment" not in viewer and "/tickets/12/edit" not in viewer
async def test_a_page_s_card_lists_its_open_tickets_and_offers_to_open_one(app):
async with app.test_request_context("/tickets/card"):
from quart import session
session["user_role"] = "operator"
card = await render_template("tickets/_card.html", tickets=[_ticket()], host="nas-1", subject="",
back="/hosts/h1")
empty = await render_template("tickets/_card.html", tickets=[], host="", subject="nas-1/restic",
back="/hosts/h1")
session["user_role"] = "viewer"
nothing = await render_template("tickets/_card.html", tickets=[], host="nas-1", subject="", back="/")
assert "Known issues (1 open)" in card and '<a href="/tickets/12">' in card
assert 'href="/tickets/new?host=nas-1&amp;back=%2Fhosts%2Fh1"' in card
assert "No open tickets." in empty and "subject=nas-1%2Frestic" in empty
assert "<section" not in nothing # a viewer, nothing open