Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.
- DELETE /api/admin/invites/{id} with a delete control on both the pending
requests and the previously-decided list. Deny keeps the row so the person
cannot immediately re-ask; delete forgets the address. Refused with 409 while
a User row exists for it, because is_allowed reads that row and removing it
would revoke a live user's access with no other sign — the list ships
has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
records an email_undelivered notification for every admin when a message is
skipped or rejected, naming the message that was lost, throttled to one an
hour. /api/me carries email_configured, driving a standing warning on
Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
views are history entries (useHistorySubview), so Back returns to the previous
view instead of only moving the address bar.
Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
116 lines
3.9 KiB
Python
116 lines
3.9 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_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
|