feat(notifications): JSON-safe webhook template, SMTP timeout and implicit TLS, test-send buttons (#5064)
#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>
This commit is contained in:
@@ -8,7 +8,7 @@ import types
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from jinja2 import Template
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,22 +30,37 @@ async def dispatch_notifications(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
SMTP_TIMEOUT_S = 15
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_mode(port, tls) -> str:
|
||||||
|
"""How to talk to the SMTP server: "ssl" (implicit TLS, port 465),
|
||||||
|
"starttls" (upgrade a plain connection, when TLS is ticked) or "plain"."""
|
||||||
|
if int(port or 0) == 465:
|
||||||
|
return "ssl"
|
||||||
|
return "starttls" if tls else "plain"
|
||||||
|
|
||||||
|
|
||||||
|
def email_body(alert: dict) -> str:
|
||||||
|
value = "no data" if alert.get("value") is None else alert["value"]
|
||||||
|
return (
|
||||||
|
f"Alert: {alert['state']}\n"
|
||||||
|
f"Rule: {alert['rule_name']}\n"
|
||||||
|
f"Resource: {alert['resource']}\n"
|
||||||
|
f"Metric: {alert['metric']} = {value}\n"
|
||||||
|
f"Threshold: {alert['threshold']}\n"
|
||||||
|
+ (f"Note: {alert['note']}\n" if alert.get("note") else "")
|
||||||
|
+ f"Time: {alert['timestamp']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||||
try:
|
try:
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["Subject"] = f"[Steward] {alert['state']} — {alert['rule_name']}"
|
msg["Subject"] = f"[Steward] {alert['state']} — {alert['rule_name']}"
|
||||||
msg["From"] = cfg.get("username", "steward@localhost")
|
msg["From"] = cfg.get("from") or cfg.get("username") or "steward@localhost"
|
||||||
msg["To"] = ", ".join(cfg["recipients"])
|
msg["To"] = ", ".join(cfg["recipients"])
|
||||||
value = "no data" if alert.get("value") is None else alert["value"]
|
msg.set_content(email_body(alert))
|
||||||
msg.set_content(
|
|
||||||
f"Alert: {alert['state']}\n"
|
|
||||||
f"Rule: {alert['rule_name']}\n"
|
|
||||||
f"Resource: {alert['resource']}\n"
|
|
||||||
f"Metric: {alert['metric']} = {value}\n"
|
|
||||||
f"Threshold: {alert['threshold']}\n"
|
|
||||||
+ (f"Note: {alert['note']}\n" if alert.get("note") else "")
|
|
||||||
+ f"Time: {alert['timestamp']}\n"
|
|
||||||
)
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
||||||
return {"sent": True, "error": None}
|
return {"sent": True, "error": None}
|
||||||
@@ -55,37 +70,59 @@ async def _send_email(cfg: dict, alert: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
||||||
ctx = ssl.create_default_context() if cfg.get("tls", True) else None
|
port = int(cfg.get("port") or 587)
|
||||||
with smtplib.SMTP(cfg["host"], cfg.get("port", 587)) as smtp:
|
mode = smtp_mode(port, cfg.get("tls", True))
|
||||||
if ctx:
|
ctx = ssl.create_default_context()
|
||||||
|
if mode == "ssl":
|
||||||
|
smtp = smtplib.SMTP_SSL(cfg["host"], port, timeout=SMTP_TIMEOUT_S, context=ctx)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(cfg["host"], port, timeout=SMTP_TIMEOUT_S)
|
||||||
|
with smtp:
|
||||||
|
if mode == "starttls":
|
||||||
smtp.starttls(context=ctx)
|
smtp.starttls(context=ctx)
|
||||||
if cfg.get("username"):
|
if cfg.get("username"):
|
||||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
def _json_str(value) -> str:
|
||||||
template_str = cfg.get(
|
"""A value as it may sit between the quotes of a JSON string."""
|
||||||
"template",
|
return json.dumps("" if value is None else str(value))[1:-1]
|
||||||
'{"content": "{{ alert.state }} — {{ alert.rule_name }}"}',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render Jinja2 template with alert as a namespace object (attribute access)
|
|
||||||
|
def render_webhook(template_str: str, alert: dict) -> str:
|
||||||
|
"""The webhook body for an alert, checked to be JSON.
|
||||||
|
|
||||||
|
In the template, `alert.<field>` is escaped to sit inside a JSON string
|
||||||
|
("{{ alert.rule_name }}" stays valid whatever the name holds), with a
|
||||||
|
missing value reading "no data"; `raw.<field>` is the plain value for use
|
||||||
|
with |tojson ({{ raw.value | tojson }} gives a number or null). The
|
||||||
|
template runs sandboxed. ValueError says why a body could not be made.
|
||||||
|
"""
|
||||||
|
shown = dict(alert)
|
||||||
|
if shown.get("value") is None:
|
||||||
|
shown["value"] = "no data"
|
||||||
|
env = SandboxedEnvironment()
|
||||||
try:
|
try:
|
||||||
alert_ns = types.SimpleNamespace(**alert)
|
rendered = env.from_string(template_str).render(
|
||||||
rendered = Template(template_str).render(alert=alert_ns)
|
alert=types.SimpleNamespace(**{k: _json_str(v) for k, v in shown.items()}),
|
||||||
|
raw=types.SimpleNamespace(**alert))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = f"Template render failed: {exc}"
|
raise ValueError(f"Template render failed: {exc}") from None
|
||||||
logger.error(error)
|
|
||||||
return {"sent": False, "error": error}
|
|
||||||
|
|
||||||
# Validate JSON before sending
|
|
||||||
try:
|
try:
|
||||||
json.loads(rendered)
|
json.loads(rendered)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
error = f"Rendered webhook template is not valid JSON: {exc}"
|
raise ValueError(f"Rendered webhook template is not valid JSON: {exc}") from None
|
||||||
logger.error(error)
|
return rendered
|
||||||
return {"sent": False, "error": error}
|
|
||||||
|
|
||||||
|
async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
||||||
|
template_str = cfg.get("template") or '{"content": "{{ alert.state }} — {{ alert.rule_name }}"}'
|
||||||
|
try:
|
||||||
|
rendered = render_webhook(template_str, alert)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error(str(exc))
|
||||||
|
return {"sent": False, "error": str(exc)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
@@ -97,6 +134,8 @@ async def _send_webhook(cfg: dict, alert: dict) -> dict:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return {"sent": True, "error": None}
|
return {"sent": True, "error": None}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc)
|
# The URL is a secret (it usually carries its own token) and httpx
|
||||||
|
# puts it in its error messages.
|
||||||
|
error = str(exc).replace(cfg["url"], "<webhook URL>")
|
||||||
logger.error(f"Webhook notification failed: {error}")
|
logger.error(f"Webhook notification failed: {error}")
|
||||||
return {"sent": False, "error": error}
|
return {"sent": False, "error": error}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"smtp.username": "",
|
"smtp.username": "",
|
||||||
"smtp.password": "",
|
"smtp.password": "",
|
||||||
"smtp.recipients": [],
|
"smtp.recipients": [],
|
||||||
|
"smtp.from": "",
|
||||||
"webhook.url": "",
|
"webhook.url": "",
|
||||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
"ansible.sources": [],
|
"ansible.sources": [],
|
||||||
@@ -370,6 +371,7 @@ def to_smtp_cfg(settings: dict[str, Any]) -> dict:
|
|||||||
"username": settings.get("smtp.username", ""),
|
"username": settings.get("smtp.username", ""),
|
||||||
"password": settings.get("smtp.password", ""),
|
"password": settings.get("smtp.password", ""),
|
||||||
"recipients": settings.get("smtp.recipients", []),
|
"recipients": settings.get("smtp.recipients", []),
|
||||||
|
"from": settings.get("smtp.from", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session
|
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from steward.core.audit import log_audit
|
from steward.core.audit import audit_request, log_audit
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from steward.core.settings import (
|
from steward.core.settings import (
|
||||||
get_all_settings, set_setting, delete_setting,
|
get_all_settings, set_setting, delete_setting,
|
||||||
@@ -229,17 +229,86 @@ async def save_notifications():
|
|||||||
if form.get("smtp.password"):
|
if form.get("smtp.password"):
|
||||||
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
||||||
await set_setting(db, "smtp.recipients", recipients)
|
await set_setting(db, "smtp.recipients", recipients)
|
||||||
|
await set_setting(db, "smtp.from", form.get("smtp.from", "").strip())
|
||||||
if "webhook.remove" in form:
|
if "webhook.remove" in form:
|
||||||
await set_setting(db, "webhook.url", "")
|
await set_setting(db, "webhook.url", "")
|
||||||
elif form.get("webhook.url", "").strip():
|
elif form.get("webhook.url", "").strip():
|
||||||
await set_setting(db, "webhook.url", form.get("webhook.url").strip())
|
await set_setting(db, "webhook.url", form.get("webhook.url").strip())
|
||||||
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
|
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
|
||||||
await _reload_app_config()
|
await _reload_app_config()
|
||||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
await audit_request("settings.saved", detail={"section": "notifications"})
|
||||||
"settings.saved", detail={"section": "notifications"})
|
|
||||||
return redirect(url_for("settings.notifications"))
|
return redirect(url_for("settings.notifications"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_alert() -> dict:
|
||||||
|
"""What a test notification carries: a made-up firing alert whose name
|
||||||
|
has quotes in it, so a template that cannot take them shows it."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
return {"rule_name": 'Test alert "from" Steward', "state": "TEST", "metric": "cpu_pct",
|
||||||
|
"value": 97.5, "threshold": 90.0, "resource": "example-host",
|
||||||
|
"source_module": "host_agent", "note": "sent from Settings → Notifications",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_test_result(channel: str, ok: bool, result: str, payload: str | None = None):
|
||||||
|
await audit_request("settings.notification.tested", detail={"channel": channel, "sent": ok})
|
||||||
|
return await render_template("settings/_notify_test.html", ok=ok, result=result,
|
||||||
|
payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.post("/notifications/test-webhook")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def test_webhook():
|
||||||
|
"""Render the webhook template (as in the form, unsaved) for a sample
|
||||||
|
alert and send it to the URL in the form, or the saved one."""
|
||||||
|
from steward.core.notifications import _send_webhook, render_webhook
|
||||||
|
form = await request.form
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
saved = to_webhook_cfg(await get_all_settings(db))
|
||||||
|
url = (form.get("webhook.url") or "").strip() or saved["url"]
|
||||||
|
template = form.get("webhook.template") or saved["template"]
|
||||||
|
alert = _sample_alert()
|
||||||
|
try:
|
||||||
|
payload = render_webhook(template, alert)
|
||||||
|
except ValueError as exc:
|
||||||
|
return await _notify_test_result("webhook", False, str(exc))
|
||||||
|
if not url:
|
||||||
|
return await _notify_test_result(
|
||||||
|
"webhook", False, "No webhook URL is saved or entered. This is the body it would get.",
|
||||||
|
payload)
|
||||||
|
sent = await _send_webhook({"url": url, "template": template}, alert)
|
||||||
|
return await _notify_test_result(
|
||||||
|
"webhook", sent["sent"], "Delivered." if sent["sent"] else sent["error"], payload)
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.post("/notifications/test-email")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def test_email():
|
||||||
|
"""Send a sample alert email using the SMTP settings in the form (unsaved);
|
||||||
|
a blank password uses the saved one."""
|
||||||
|
from steward.core.notifications import _send_email, email_body, smtp_mode
|
||||||
|
form = await request.form
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
saved = to_smtp_cfg(await get_all_settings(db))
|
||||||
|
try:
|
||||||
|
port = int(form.get("smtp.port") or 587)
|
||||||
|
except ValueError:
|
||||||
|
return await _notify_test_result("email", False, "The port must be a number.")
|
||||||
|
cfg = {"host": (form.get("smtp.host") or "").strip(), "port": port,
|
||||||
|
"tls": "smtp.tls" in form, "username": form.get("smtp.username", ""),
|
||||||
|
"password": form.get("smtp.password") or saved["password"],
|
||||||
|
"from": (form.get("smtp.from") or "").strip(),
|
||||||
|
"recipients": [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]}
|
||||||
|
if not cfg["host"] or not cfg["recipients"]:
|
||||||
|
return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.")
|
||||||
|
alert = _sample_alert()
|
||||||
|
sent = await _send_email(cfg, alert)
|
||||||
|
how = {"ssl": "implicit TLS", "starttls": "STARTTLS", "plain": "no TLS"}[smtp_mode(port, cfg["tls"])]
|
||||||
|
result = (f"Sent to {', '.join(cfg['recipients'])} via {cfg['host']}:{port} ({how})."
|
||||||
|
if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}")
|
||||||
|
return await _notify_test_result("email", sent["sent"], result, email_body(alert))
|
||||||
|
|
||||||
|
|
||||||
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _get_ansible_sources() -> list[dict]:
|
async def _get_ansible_sources() -> list[dict]:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{# settings/_notify_test.html — result of a test send from Settings → Notifications #}
|
||||||
|
<div class="alert {% if ok %}alert-success{% else %}alert-error{% endif %}" style="margin:0.75rem 0 0;">
|
||||||
|
{{ result }}
|
||||||
|
</div>
|
||||||
|
{% if payload %}
|
||||||
|
<p style="font-size:0.75rem;color:var(--text-muted);margin:0.5rem 0 0.2rem;">What was sent:</p>
|
||||||
|
<pre style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:pre-wrap;word-break:break-word;margin:0;">{{ payload }}</pre>
|
||||||
|
{% endif %}
|
||||||
@@ -28,9 +28,11 @@
|
|||||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
|
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
|
||||||
<input type="checkbox" name="smtp.tls" id="smtp-tls"
|
<input type="checkbox" name="smtp.tls" id="smtp-tls"
|
||||||
{% if settings['smtp.tls'] %}checked{% endif %}>
|
{% if settings['smtp.tls'] %}checked{% endif %}>
|
||||||
<label for="smtp-tls" style="margin:0;">Use TLS</label>
|
<label for="smtp-tls" style="margin:0;">Use STARTTLS</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p style="font-size:0.75rem;color:var(--text-muted);margin:-0.5rem 0 1rem;">
|
||||||
|
Port 465 always connects with implicit TLS; on other ports the box upgrades the connection with STARTTLS.</p>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
@@ -43,12 +45,23 @@
|
|||||||
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
|
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>From address <span style="color:var(--text-muted);font-size:0.8rem;">(blank uses the username)</span></label>
|
||||||
|
<input type="text" name="smtp.from" value="{{ settings['smtp.from'] }}"
|
||||||
|
placeholder="steward@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Recipients <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
|
<label>Recipients <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
|
||||||
<input type="text" name="smtp.recipients"
|
<input type="text" name="smtp.recipients"
|
||||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||||
placeholder="admin@example.com, ops@example.com">
|
placeholder="admin@example.com, ops@example.com">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"
|
||||||
|
hx-include="closest form" hx-target="#email-test-result" hx-swap="innerHTML">
|
||||||
|
Send a test email</button>
|
||||||
|
<div id="email-test-result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Webhook ───────────────────────────────────────────────────────────── #}
|
{# ── Webhook ───────────────────────────────────────────────────────────── #}
|
||||||
@@ -73,7 +86,17 @@
|
|||||||
<label>Payload template <span style="color:var(--text-muted);font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
|
<label>Payload template <span style="color:var(--text-muted);font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
|
||||||
<textarea name="webhook.template" rows="4"
|
<textarea name="webhook.template" rows="4"
|
||||||
style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
|
style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
|
||||||
|
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
|
||||||
|
<code>{{ '{{ alert.rule_name }}' }}</code> is escaped to sit inside a JSON string, so quotes or
|
||||||
|
line breaks in a name can't break the body; a missing value reads "no data".
|
||||||
|
For a bare JSON value use <code>{{ '{{ raw.value | tojson }}' }}</code> (a number, or null).
|
||||||
|
Fields: state, rule_name, resource, metric, value, threshold, source_module, note, timestamp.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-webhook"
|
||||||
|
hx-include="closest form" hx-target="#webhook-test-result" hx-swap="innerHTML">
|
||||||
|
Send a test webhook</button>
|
||||||
|
<div id="webhook-test-result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Notification delivery (#5064): the webhook body is JSON whatever an alert
|
||||||
|
holds, SMTP picks implicit TLS / STARTTLS / plain from port and box and never
|
||||||
|
waits without a timeout, and a webhook error never carries the URL."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from steward.core import notifications as n
|
||||||
|
|
||||||
|
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
|
||||||
|
"value": 93.5, "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
|
||||||
|
"note": None, "timestamp": "2026-10-06T12:00:00+00:00"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── render_webhook ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_alert_fields_are_escaped_into_json_strings():
|
||||||
|
body = n.render_webhook('{"content": "{{ alert.state }} — {{ alert.rule_name }}"}', ALERT)
|
||||||
|
assert json.loads(body) == {"content": 'FIRING — Disk "root" on\nweb01 \\ backslash'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_template_survives_quotes_in_a_name():
|
||||||
|
from steward.core.settings import DEFAULTS
|
||||||
|
body = json.loads(n.render_webhook(DEFAULTS["webhook.template"], ALERT))
|
||||||
|
assert 'Disk "root" on\nweb01' in body["content"] and "93.5" in body["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_fields_give_bare_json_values():
|
||||||
|
tpl = '{"value": {{ raw.value | tojson }}, "name": {{ raw.rule_name | tojson }}}'
|
||||||
|
assert json.loads(n.render_webhook(tpl, ALERT)) == {"value": 93.5, "name": ALERT["rule_name"]}
|
||||||
|
assert json.loads(n.render_webhook(tpl, {**ALERT, "value": None}))["value"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_value_reads_no_data():
|
||||||
|
body = n.render_webhook('{"v": "{{ alert.value }}", "note": "{{ alert.note }}"}', {**ALERT, "value": None})
|
||||||
|
assert json.loads(body) == {"v": "no data", "note": ""}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tpl, says", [
|
||||||
|
('{"v": {{ alert.value }', "Template render failed"), # Jinja syntax error
|
||||||
|
('{"v": {{ raw.rule_name }}}', "not valid JSON"), # unquoted string
|
||||||
|
("{{ raw.__class__.__mro__ }}", "Template render failed"), # sandbox refuses
|
||||||
|
])
|
||||||
|
def test_a_bad_template_raises_valueerror_saying_why(tpl, says):
|
||||||
|
with pytest.raises(ValueError, match=says):
|
||||||
|
n.render_webhook(tpl, ALERT)
|
||||||
|
|
||||||
|
|
||||||
|
# ── smtp_mode / _smtp_send_sync ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("port, tls, mode", [
|
||||||
|
(465, True, "ssl"), (465, False, "ssl"), ("465", False, "ssl"),
|
||||||
|
(587, True, "starttls"), (587, False, "plain"),
|
||||||
|
(25, True, "starttls"), (25, False, "plain"),
|
||||||
|
(None, True, "starttls"), (None, False, "plain"),
|
||||||
|
])
|
||||||
|
def test_smtp_mode(port, tls, mode):
|
||||||
|
assert n.smtp_mode(port, tls) == mode
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSMTP:
|
||||||
|
made: list = []
|
||||||
|
|
||||||
|
def __init__(self, host, port, timeout=None, context=None):
|
||||||
|
self.calls = [("connect", type(self).__name__, host, port, timeout)]
|
||||||
|
_FakeSMTP.made.append(self)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def starttls(self, context=None):
|
||||||
|
self.calls.append(("starttls",))
|
||||||
|
|
||||||
|
def login(self, user, password):
|
||||||
|
self.calls.append(("login", user, password))
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
self.calls.append(("send", msg["From"]))
|
||||||
|
|
||||||
|
|
||||||
|
class SMTP(_FakeSMTP):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SMTP_SSL(_FakeSMTP):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("port, tls, expected", [
|
||||||
|
(465, True, [("connect", "SMTP_SSL", "mx", 465, n.SMTP_TIMEOUT_S)]),
|
||||||
|
(587, True, [("connect", "SMTP", "mx", 587, n.SMTP_TIMEOUT_S), ("starttls",)]),
|
||||||
|
(25, False, [("connect", "SMTP", "mx", 25, n.SMTP_TIMEOUT_S)]),
|
||||||
|
])
|
||||||
|
def test_smtp_send_connects_by_mode_with_a_timeout(monkeypatch, port, tls, expected):
|
||||||
|
monkeypatch.setattr(n.smtplib, "SMTP", SMTP)
|
||||||
|
monkeypatch.setattr(n.smtplib, "SMTP_SSL", SMTP_SSL)
|
||||||
|
_FakeSMTP.made = []
|
||||||
|
msg = n.EmailMessage()
|
||||||
|
msg["From"] = "steward@example.com"
|
||||||
|
n._smtp_send_sync({"host": "mx", "port": port, "tls": tls, "username": "u", "password": "p"}, msg)
|
||||||
|
(smtp,) = _FakeSMTP.made
|
||||||
|
assert smtp.calls == expected + [("login", "u", "p"), ("send", "steward@example.com")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_from_address_falls_back_to_username_then_localhost(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(n, "_smtp_send_sync", lambda cfg, msg: sent.append(msg["From"]))
|
||||||
|
base = {"host": "mx", "port": 587, "recipients": ["a@example.com"]}
|
||||||
|
for cfg in ({**base, "from": "alerts@example.com", "username": "u@example.com"},
|
||||||
|
{**base, "from": "", "username": "u@example.com"},
|
||||||
|
{**base, "from": "", "username": ""}):
|
||||||
|
assert (await n._send_email(cfg, ALERT))["sent"]
|
||||||
|
assert sent == ["alerts@example.com", "u@example.com", "steward@localhost"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_body_names_no_data_and_the_note():
|
||||||
|
body = n.email_body({**ALERT, "value": None, "note": "no sample for 18m"})
|
||||||
|
assert "Metric: disk_used_pct = no data\n" in body and "Note: no sample for 18m\n" in body
|
||||||
|
assert "Note:" not in n.email_body(ALERT)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _send_webhook ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _client_with(monkeypatch, handler):
|
||||||
|
real = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(n.httpx, "AsyncClient",
|
||||||
|
lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_webhook_error_never_carries_the_url(monkeypatch):
|
||||||
|
url = "https://discord.example/api/webhooks/123/s3cr3t-token"
|
||||||
|
_client_with(monkeypatch, lambda req: httpx.Response(404))
|
||||||
|
result = await n._send_webhook({"url": url, "template": ""}, ALERT)
|
||||||
|
assert result["sent"] is False
|
||||||
|
assert "s3cr3t-token" not in result["error"] and "<webhook URL>" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_webhook_posts_the_rendered_json(monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
seen.append(json.loads(req.content))
|
||||||
|
return httpx.Response(204)
|
||||||
|
|
||||||
|
_client_with(monkeypatch, handler)
|
||||||
|
result = await n._send_webhook({"url": "https://hook.example/x", "template": ""}, ALERT)
|
||||||
|
assert result == {"sent": True, "error": None}
|
||||||
|
assert seen == [{"content": f'FIRING — {ALERT["rule_name"]}'}]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""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())
|
||||||
Reference in New Issue
Block a user