Round 3's correctness/security pass came back clean; what was left said more than the code does. - emailOff promised the access-request list is where sign-ups show up. A sign-up on an address that is already approved or already pending adds nothing to it (roadmap UX-25), and "only" was falsified by this release's own inbox notice. - deleteConfirm claimed the admin was dropping "an approval you already gave" — it is the confirmation for pending and denied rows too. - sendFailed blamed the mail server for rejecting a message; the reason covers connect, DNS, TLS and login failures alike, so it now describes the symptom and leaves the diagnosis to the log. - ADMIN_EMAILS re-promotion happens in the Google callback only, not on any sign-in. Tests: the decided-list copy of the delete affordance was never rendered (both new cases fail if that branch is dropped), and the throttle release — a deliberate behaviour the notes advertise — was asserted by nothing; a drop with no admin yet, or with a failing write, must not silence the following hour.
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
"""An undelivered email leaves a trace in the admins' inbox.
|
|
|
|
Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns
|
|
False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose
|
|
SMTP was never filled in swallowed verification links, password resets and access-request notices,
|
|
and the operator's first clue was somebody saying nothing arrived.
|
|
"""
|
|
|
|
import smtplib
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app import email as email_mod
|
|
from app.models import Notification
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_smtp_config(monkeypatch):
|
|
"""Every test here drives the real `_send`, so make sure it never reaches a socket."""
|
|
monkeypatch.setattr(
|
|
email_mod,
|
|
"_smtp",
|
|
lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""},
|
|
)
|
|
|
|
|
|
def _notifications(db, user_id: int) -> list[Notification]:
|
|
return list(
|
|
db.execute(
|
|
select(Notification)
|
|
.where(Notification.user_id == user_id, Notification.type == "email_undelivered")
|
|
.order_by(Notification.id)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
def test_a_skipped_email_notifies_every_admin(db, make_user):
|
|
admin_a = make_user(email="admin-a@example.com", role="admin")
|
|
admin_b = make_user(email="admin-b@example.com", role="admin")
|
|
plain = make_user(email="plain@example.com")
|
|
|
|
assert email_mod.send_test("someone@example.com") is False # fail-soft, as before
|
|
|
|
db.expire_all()
|
|
for admin in (admin_a, admin_b):
|
|
(n,) = _notifications(db, admin.id)
|
|
assert n.data["reason"] == "not_configured"
|
|
assert n.data["subject"] == "Siftlode SMTP test"
|
|
assert n.data["recipients"] == ["someone@example.com"]
|
|
assert _notifications(db, plain.id) == [] # not an operator's problem to see
|
|
|
|
|
|
def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user):
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
|
|
for _ in range(5):
|
|
email_mod.send_test("someone@example.com")
|
|
|
|
db.expire_all()
|
|
assert len(_notifications(db, admin.id)) == 1
|
|
|
|
|
|
def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch):
|
|
# A configured-but-rejecting relay is a different operator problem from an empty config, and the
|
|
# inbox copy differs — so the reason has to survive to the notification.
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
monkeypatch.setattr(
|
|
email_mod,
|
|
"_smtp",
|
|
lambda: {
|
|
"host": "smtp.example.com",
|
|
"port": 587,
|
|
"user": "u",
|
|
"password": "p",
|
|
"from": "Siftlode <u@example.com>",
|
|
},
|
|
)
|
|
|
|
def _boom(*a, **kw):
|
|
raise smtplib.SMTPException("nope")
|
|
|
|
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
|
|
|
|
assert email_mod.send_test("someone@example.com") is False
|
|
|
|
db.expire_all()
|
|
(n,) = _notifications(db, admin.id)
|
|
assert n.data["reason"] == "send_failed"
|
|
|
|
|
|
def test_no_admins_means_no_notification_and_no_error(db, make_user):
|
|
# A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent
|
|
# rather than blowing up inside the fail-soft path it is supposed to make visible.
|
|
plain = make_user(email="plain@example.com")
|
|
|
|
assert email_mod.send_test("someone@example.com") is False
|
|
|
|
db.expire_all()
|
|
assert _notifications(db, plain.id) == []
|
|
|
|
|
|
def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
|
|
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
|
|
# during setup — before any admin exists — silences the next hour for the admin created a minute
|
|
# later, which is exactly the blind spot this alert exists to remove.
|
|
email_mod.send_test("someone@example.com") # no admins yet
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
|
|
assert email_mod.send_test("someone@example.com") is False
|
|
|
|
db.expire_all()
|
|
assert len(_notifications(db, admin.id)) == 1
|
|
|
|
|
|
def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch):
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
boom = {"on": True}
|
|
|
|
def _maybe_boom(*a, **kw):
|
|
if boom["on"]:
|
|
raise RuntimeError("notification store is down")
|
|
from app.notifications import create_notification as real
|
|
|
|
return real(*a, **kw)
|
|
|
|
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
|
|
email_mod.send_test("someone@example.com") # the write blows up; nothing recorded
|
|
boom["on"] = False
|
|
|
|
assert email_mod.send_test("someone@example.com") is False
|
|
|
|
db.expire_all()
|
|
assert len(_notifications(db, admin.id)) == 1
|
|
|
|
|
|
def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
|
|
# The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft
|
|
# False rather than an exception surfacing from a background task.
|
|
make_user(email="admin@example.com", role="admin")
|
|
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("notification store is down")
|
|
|
|
monkeypatch.setattr(email_mod, "create_notification", _boom)
|
|
|
|
assert email_mod.send_test("someone@example.com") is False
|