Files
FabledSteward/tests/core/test_alerts_page.py
T
bvandeusenandClaude Opus 5.5 22090b02d8
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m0s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m32s
feat(email): each account gets its own email, in its own zone (milestone 495)
Milestone 495 "Email goes to each account, in its own time zone", on the
operator's ruling (System 156): notifications go to each user, times in
their zone, defaults on, toggled per user or per role.

- users.notify_alerts / notify_report (migration 0036): None follows the
  role's default, notify.<kind>.<role> in settings, all on to start.
- mail.pick_recipients: active accounts that want the email and have an
  address mail can reach (an SSO placeholder like name@oidc is skipped and
  flagged), each in its own zone with no UTC beside it, then the Also send
  to addresses in their chosen zone with UTC beside. One copy per address,
  all over one SMTP connection.
- Alerts, the weekly report and the test email (to you, in your zone, plus
  the typed addresses) send that way. The save-time smtp.tz capture is gone.
- Notifications: a role × email table of defaults, who each email reaches
  and who can't be reached, and Also send to with its own zone, which starts
  as the first admin's to open the page.
- Account page: your address and your two switches. Users: an Emailed
  column, and an admin sets an account's switches on its page.
- The Alerts page counts the accounts it emails. zone_select macro and
  formatting.zones_by_region shared by Account and Notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:17 -04:00

275 lines
15 KiB
Python

"""The Alerts page says each alert as a sentence around a link to what it
watches, offers only the actions the viewer's role can take, and the sidebar
counts what needs attention (#5124)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from quart import render_template, session
from steward.alerts import service as alert_svc
from steward.alerts.service import alert_sentence
from steward.models.alerts import AlertOperator, AlertStateEnum
from tests.test_style_tokens import hex_colours
def _rule(metric="cpu_pct", op=AlertOperator.gt, threshold=90.0, resource="web-1", rid="r1"):
return SimpleNamespace(id=rid, name=f"{resource} {metric}", source_module="host_agent",
resource_name=resource, metric_name=metric, operator=op,
threshold=threshold, enabled=True,
consecutive_failures_required=1, consecutive_ok_required=1)
def _said(says):
return says["lead"] + says["resource"] + says["tail"]
@pytest.mark.parametrize("op, threshold, value, reads", [
(AlertOperator.gt, 90.0, 94.0, "CPU on web-1 is 94% (> 90%), firing for 12m"),
(AlertOperator.gte, 90.0, 90.0, "CPU on web-1 is 90% (>= 90%), firing for 12m"),
(AlertOperator.lt, 5.0, 2.5, "CPU on web-1 is 2.5% (< 5.0%), firing for 12m"),
(AlertOperator.lte, 5.0, 5.0, "CPU on web-1 is 5.0% (<= 5.0%), firing for 12m"),
(AlertOperator.eq, 100.0, 100.0, "CPU on web-1 is 100% (== 100%), firing for 12m"),
(AlertOperator.ne, 0.0, 3.0, "CPU on web-1 is 3.0% (!= 0.0%), firing for 12m"),
])
def test_a_firing_alert_reads_as_one_sentence(op, threshold, value, reads):
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(op=op, threshold=threshold), "firing", value,
now - timedelta(minutes=12), now)
assert says["resource"] == "web-1"
assert _said(says) == reads
def test_units_follow_the_metric_name():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule("response_ms", threshold=200.0, resource="api"), "pending", 431.0,
now - timedelta(seconds=45), now)
assert _said(says) == "Response time on api is 431 ms (> 200 ms), pending for 45s"
def test_an_up_or_down_metric_says_the_word():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule("is_up", AlertOperator.lt, 1.0, "nas"), "acknowledged", 0.0,
now - timedelta(hours=2, minutes=5), now)
# Acknowledged is still breaching: the sentence says for how long (#5210).
assert _said(says) == "nas is down, breaching for 2h 5m"
def test_no_reading_yet_is_said_not_shown_as_a_number():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(), "firing", None, now - timedelta(minutes=3), now)
assert _said(says) == "CPU on web-1 has no reading yet (> 90%), firing for 3m"
def test_a_silent_series_says_how_long_it_has_been_quiet():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(), "no_data", 50.0, now - timedelta(minutes=20), now)
assert _said(says) == "No CPU reading from web-1 for 20m"
def _row(rule, label, url, state=AlertStateEnum.firing):
now = datetime.now(timezone.utc)
st = SimpleNamespace(state=state, no_data_since=None, last_sample_at=None,
last_transition_at=now - timedelta(minutes=12))
return {"state": st, "rule": rule, "label": label, "url": url,
"says": alert_sentence(rule, label, 94.0, st.last_transition_at, now)}
async def test_an_operator_sees_the_sentence_linked_and_can_acknowledge(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "operator"
page = await render_template("alerts/list.html", active=[_row(rule, "firing", "/hosts/h1")],
rules=[rule], operators=[])
assert 'CPU on <a href="/hosts/h1">web-1</a> is 94% (&gt; 90%), firing for 12m' in page
assert '<span class="badge badge-crit">Firing</span>' in page
assert 'action="/alerts/r1/acknowledge"' in page
assert 'href="/alerts/rules/new"' in page and 'href="/alerts/rules/r1/edit"' in page
# The whole row opens the rule: one stretched link, the row marked for it.
row = page[page.index('<tr class="row-link'):]
row = row[:row.index("</tr>")]
assert row.count('class="row-target"') == 1 and 'class="row-target" href="/alerts/rules/r1/edit"' in row
assert 'action="/alerts/rules/r1/delete"' not in page # deleting a rule is the admin's
main = page[page.index("<main"):] # the sidebar brand mark carries its own colours
assert "onclick" not in main and not hex_colours(main), hex_colours(main)
async def test_a_viewer_reads_but_gets_no_actions(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "viewer"
page = await render_template("alerts/list.html", active=[_row(rule, "firing", None)],
rules=[rule], operators=[])
assert "CPU on <strong>web-1</strong>" in page # nothing to link to: no bare href
for action in ("/acknowledge", "/alerts/rules/new", "/edit", "/toggle", "/delete"):
assert action not in page, action
assert 'href="/alerts/history?rule=r1"' in page
async def test_deleting_a_rule_is_on_its_own_page_and_says_what_goes(app):
"""#5317: the list offers no Delete; the rule's page does, behind the
confirm dialog, saying its history goes with it."""
rule = _rule()
rule.ansible_action, rule.notify_no_data = None, True
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
listing = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=["web-1"], metrics=["cpu_pct"], ansible_sources=[],
catalog={}, history_count=12, fired=None, notify=[])
assert "/delete" not in listing
assert 'action="/alerts/rules/r1/delete"' in page
assert ('data-confirm="Delete rule web-1 cpu_pct? Its history goes with it (12 changes of state). '
'Disabling the rule keeps it."') in page
assert page.index('id="rule-watches"') < page.index('id="rule-fires"') < page.index('id="rule-happens"') \
< page.index('id="rule-delete"')
async def test_the_rule_page_is_named_for_the_rule(app):
"""#5317: the title and crumb are the rule's name; seconds read back as a
duration; the playbook's fields wait for their box; where alerts go is said."""
rule = _rule("seconds_since_seen", AlertOperator.gt, 180.0, "VDNT-Docker02")
rule.name, rule.ansible_action, rule.notify_no_data = "Agent offline: VDNT-Docker02", None, True
async with app.test_request_context("/alerts/rules/r1/edit"):
session["user_role"] = "admin"
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=[(">", ">")], source_modules=["host_agent"],
initial_source="host_agent", resources=["VDNT-Docker02"], metrics=["seconds_since_seen"],
ansible_sources=[], catalog={}, history_count=10,
fired=datetime.now(timezone.utc) - timedelta(hours=6), notify=["email to me@example.com"])
page = " ".join(page.split())
assert '<h1 class="page-title">Agent offline: VDNT-Docker02</h1>' in page
assert '<span class="breadcrumb-cur" aria-current="page">Agent offline: VDNT-Docker02</span>' in page
assert "Edit Rule" not in page and "Last fired <time" in page and "10 changes of state on record" in page
assert 'value="180" placeholder="90">' in page and "data-threshold-echo >= 3m</p>" in page
assert "data-ansible-fields hidden" in page
assert "It notifies email to me@example.com." in page
def test_where_alerts_go_is_said_as_the_dispatcher_decides():
"""#5315: email needs a server and recipients; a webhook its URL."""
assert alert_svc.notify_targets({}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}, accounts=3) == ["email to 3 accounts"]
assert alert_svc.notify_targets({"host": "mx", "recipients": ["ops@x"]}, {}, accounts=1) == [
"email to 1 account, ops@x"]
assert alert_svc.notify_targets({"host": "", "recipients": []}, {}, accounts=3) == [] # no server, no email
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
== ["email to a@x, b@x", "a webhook to hooks.example"]
async def test_the_page_opens_on_a_verdict_and_says_where_alerts_go(app):
"""#5315: no card for an empty Active list; one line instead."""
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
quiet = await render_template("alerts/list.html", active=[], rules=[rule], operators=[], notify=[])
firing = await render_template("alerts/list.html", active=[_row(rule, "firing", None)], rules=[rule],
operators=[], notify=["a webhook to hooks.example"],
last_fired={"r1": datetime.now(timezone.utc) - timedelta(minutes=12)})
quiet, firing = " ".join(quiet.split()), " ".join(firing.split())
assert '<strong class="tone-ok">Nothing is firing</strong> · 1 rule ·' in quiet
assert "notifies nobody yet</span> — <a href=\"/settings/notifications/\">set up email or a webhook</a>" in quiet
assert "empty-state" not in quiet and ">Active</h2>" not in quiet
assert '<strong class="tone-crit">1 firing</strong>' in firing and "notifies a webhook to hooks.example" in firing
assert "last fired <time" in firing and "never fired" in quiet
assert "on the first check" in quiet
async def _active_count(_db):
return 3
async def test_the_sidebar_counts_alerts_needing_attention(app, monkeypatch):
monkeypatch.setattr(alert_svc, "active_count", _active_count)
async with app.test_request_context("/alerts/"):
session["user_id"], session["user_role"] = "u1", "viewer"
page = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert '<span class="count-badge" title="3 alerts need attention">3</span>' in page
async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
monkeypatch.setattr(alert_svc, "active_count", _active_count)
async with app.test_request_context("/alerts/", headers={"HX-Request": "true"}):
session["user_id"], session["user_role"] = "u1", "viewer"
fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[])
async with app.test_request_context("/alerts/"):
logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert "count-badge" not in fragment and "count-badge" not in logged_out
async def test_an_acknowledged_alert_says_who_and_when_apart_from_the_breach(app):
rule = _rule("is_up", AlertOperator.lt, 1.0, "nas")
now = datetime.now(timezone.utc)
st = SimpleNamespace(state=AlertStateEnum.acknowledged, no_data_since=None, last_sample_at=None,
last_transition_at=now - timedelta(minutes=3),
acknowledged_at=now - timedelta(minutes=3))
row = {"state": st, "rule": rule, "label": "acknowledged", "url": None, "acked_by": "bryan",
"says": alert_sentence(rule, "acknowledged", 0.0, now - timedelta(hours=1), now)}
async with app.test_request_context("/alerts/active"):
session["user_role"] = "operator"
page = await render_template("alerts/_active.html", active=[row])
page = " ".join(page.split())
assert "<strong>nas</strong> is down, breaching for 1h" in page
assert "Acknowledged by bryan <time" in page and ">3m ago</time>" in page
assert "/acknowledge" not in page # already taken
async def test_rules_say_what_each_is_doing_now(app):
firing, quiet, off = _rule(rid="r1"), _rule(rid="r2", resource="db"), _rule(rid="r3", resource="old")
off.enabled = False
async with app.test_request_context("/alerts/"):
session["user_role"] = "viewer"
page = await render_template("alerts/list.html", active=[], rules=[firing, quiet, off],
rule_state={"r1": "firing", "r2": "inactive"}, operators=[])
page = " ".join(page.split())
rules = page[page.index(">Rules</h2>"):]
assert '<span class="badge badge-crit">Firing</span>' in rules
assert 'title="Within its threshold">quiet</span>' in rules
assert '<span class="tone-dim">off</span>' in rules and 'class="row-link row-off"' in rules
assert 'hx-get="/alerts/active"' in page
def test_a_rule_condition_reads_in_words():
"""#5212: "is down", not "is_up < 0.5"; a threshold in its unit."""
from steward.alerts.service import rule_condition
assert rule_condition(_rule("is_up", AlertOperator.lt, 0.5)) == "is down"
assert rule_condition(_rule("is_healthy", AlertOperator.gte, 0.5)) == "is healthy"
assert rule_condition(_rule("disk_used_pct_worst", AlertOperator.gte, 85.0)) == "Fullest disk >= 85%"
assert rule_condition(_rule("seconds_since_seen", AlertOperator.gt, 180.0)) == \
"Time since the agent reported > 3m"
assert rule_condition(_rule("hrProcessorLoad", AlertOperator.gt, 3.0)) == "hrProcessorLoad > 3"
def test_the_form_turns_up_or_down_into_a_condition():
from steward.alerts.routes import _form_condition
assert _form_condition({"metric_name": "is_up", "binary_when": "down"}) == \
{"operator": "<", "threshold": "0.5"}
assert _form_condition({"metric_name": "is_healthy", "binary_when": "up"}) == \
{"operator": ">=", "threshold": "0.5"}
# Any other metric keeps what was typed, whatever binary_when says.
assert _form_condition({"metric_name": "cpu_pct", "binary_when": "down", "operator": ">",
"threshold": "90"}) == {"operator": ">", "threshold": "90"}
async def test_the_rule_form_speaks_in_names_and_units(app):
from steward.alerts.routes import _OPERATORS
rule = _rule("is_up", AlertOperator.lt, 0.5, "nas")
rule.source_module, rule.ansible_action, rule.notify_no_data = "http", None, True
async with app.test_request_context("/alerts/rules/r1/edit"):
session["user_role"] = "operator"
page = await render_template(
"alerts/rules_form.html", rule=rule, operators=_OPERATORS,
source_modules=["http", "host_agent"], initial_source="http", resources=["nas"],
metrics=["is_up", "response_ms"], ansible_sources=[],
catalog={"http": ["is_up", "response_ms"], "snmp": []})
assert ">HTTP monitors</option>" in page and ">Host agent</option>" in page
assert ">Up or down</option>" in page and 'data-unit="ms"' in page
assert '<option value="down" selected>' in page # the binary choice, shown
assert 'name="threshold" step="any" required disabled' in page # the raw threshold, off
assert ">is_up<" not in page and "SNMP" not in page # no raw key; empty source skipped