#5064 "Notifications that arrive": - render_webhook: alert.* is escaped to sit inside a JSON string (a quote or newline in a rule name no longer breaks the body); raw.* is the plain value for |tojson; a missing value reads "no data"; the template runs sandboxed and the body is checked to be JSON before it is sent. - SMTP: 15s timeout; port 465 connects with implicit TLS, otherwise the box means STARTTLS; a From address setting (falls back to the username). - A webhook error no longer carries the URL, which is a secret. - Settings → Notifications: "Send a test email" / "Send a test webhook" send a sample alert with the form's values (unsaved) and show the result and the body that went out; template help names alert.* and raw.*. - Notification audits go through audit_request; the test send is audited as settings.notification.tested, with the channel and outcome only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
"""Integration: the test-send buttons on Settings → Notifications (#5064).
|
|
|
|
Each one sends a sample alert with the form's values, unsaved, and answers
|
|
with what happened and the body that went out. The routes run for real
|
|
against Postgres; only the delivery itself is replaced.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@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 _admin_client(app):
|
|
from steward.models.users import User, UserRole
|
|
uid = str(uuid.uuid4())
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
s.add(User(id=uid, username=f"notify-{uid[:8]}", email=f"{uid}@test",
|
|
password_hash="x", role=UserRole.admin, is_active=True))
|
|
client = app.test_client()
|
|
async with client.session_transaction() as sess:
|
|
sess["user_id"] = uid
|
|
sess["user_role"] = "admin"
|
|
sess["username"] = f"notify-{uid[:8]}"
|
|
sess["_csrf"] = "test-token"
|
|
sess["login_at"] = time.time()
|
|
return client, uid
|
|
|
|
|
|
async def _audits(app, uid):
|
|
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)) for r in rows]
|
|
|
|
|
|
def test_test_webhook_sends_the_form_template_and_shows_the_body(app, monkeypatch):
|
|
from steward.core import notifications
|
|
sent = []
|
|
|
|
async def fake_send(cfg, alert):
|
|
sent.append((cfg, alert))
|
|
return {"sent": False, "error": "Client error '404 Not Found' for url '<webhook URL>'"}
|
|
|
|
monkeypatch.setattr(notifications, "_send_webhook", fake_send)
|
|
|
|
async def _go():
|
|
client, uid = await _admin_client(app)
|
|
csrf = {"X-CSRF-Token": "test-token"}
|
|
url = "https://hook.example/api/webhooks/1/s3cr3t"
|
|
resp = await client.post("/settings/notifications/test-webhook", form={
|
|
"webhook.url": url, "webhook.template": '{"text": "{{ alert.rule_name }}"}'}, headers=csrf)
|
|
assert resp.status_code == 200
|
|
page = await resp.get_data(as_text=True)
|
|
assert "alert-error" in page and "404 Not Found" in page
|
|
# The body shown is the one that would go out, and the quoted name kept it JSON.
|
|
assert "Test alert \\"from\\" Steward" in page
|
|
((cfg, alert),) = sent
|
|
assert cfg["url"] == url and alert["rule_name"] == 'Test alert "from" Steward'
|
|
|
|
# A template that cannot make JSON is refused before anything is sent.
|
|
resp = await client.post("/settings/notifications/test-webhook", form={
|
|
"webhook.url": url, "webhook.template": '{"text": {{ raw.rule_name }}}'}, headers=csrf)
|
|
page = await resp.get_data(as_text=True)
|
|
assert "not valid JSON" in page and len(sent) == 1
|
|
|
|
audits = await _audits(app, uid)
|
|
assert [a for a, _ in audits] == ["settings.notification.tested"] * 2
|
|
assert all(d == {"channel": "webhook", "sent": False} for _, d in audits)
|
|
assert "s3cr3t" not in json.dumps([d for _, d in audits])
|
|
|
|
asyncio.run(_go())
|
|
|
|
|
|
def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypatch):
|
|
from steward.core import notifications
|
|
sent = []
|
|
|
|
async def fake_send(cfg, alert):
|
|
sent.append(cfg)
|
|
return {"sent": True, "error": None}
|
|
|
|
monkeypatch.setattr(notifications, "_send_email", fake_send)
|
|
|
|
async def _go():
|
|
client, uid = await _admin_client(app)
|
|
csrf = {"X-CSRF-Token": "test-token"}
|
|
resp = await client.post("/settings/notifications/test-email", form={
|
|
"smtp.host": "mx.example", "smtp.port": "465", "smtp.username": "u",
|
|
"smtp.password": "pw", "smtp.from": "alerts@example.com",
|
|
"smtp.recipients": "a@example.com, b@example.com"}, headers=csrf)
|
|
page = await resp.get_data(as_text=True)
|
|
assert "alert-success" in page
|
|
assert "Sent to a@example.com, b@example.com via mx.example:465 (implicit TLS)." in page
|
|
assert "Rule: Test alert" in page
|
|
assert sent == [{"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
|
"password": "pw", "from": "alerts@example.com",
|
|
"recipients": ["a@example.com", "b@example.com"]}]
|
|
|
|
resp = await client.post("/settings/notifications/test-email", form={
|
|
"smtp.host": "", "smtp.port": "587", "smtp.recipients": ""}, headers=csrf)
|
|
assert "Enter an SMTP host and at least one recipient." in await resp.get_data(as_text=True)
|
|
assert len(sent) == 1
|
|
|
|
assert [d for _, d in await _audits(app, uid)] == [
|
|
{"channel": "email", "sent": True}, {"channel": "email", "sent": False}]
|
|
|
|
asyncio.run(_go())
|