Files
siftlode/backend/tests/test_welcome_mail.py
T
peter 99df44d7fb fix(api): mutations answer ok; route helpers flush and leave the commit to the handler
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:

- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
  `{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
  reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
  flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
  mark, and the invite decision + account activation — which an approval used to land in separate
  transactions. The other six commit on purpose (a token durable before its link is mailed, a
  single-use token burned as its own unit, the Google callback's tail, a background registration
  with its own session, the staged search ingest around YouTube calls), each listed with its reason.

`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
2026-09-19 18:21:06 +02:00

250 lines
9.5 KiB
Python

"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
another asking them to confirm their address. The C option carries the confirmation link inside the
mail that is already going out, so the usual case is one mail and one click.
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
sent nothing at all, while answering with the same cheerful "check your inbox".
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _capture(monkeypatch, name):
"""Record the arguments a given email_mod.send_* was called with."""
from app import email as email_mod
calls: list[tuple] = []
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
return calls
def _smtp_on(monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
def _approve(client, invite_id):
r = client.post(f"/api/admin/invites/{invite_id}/approve")
assert r.status_code == 200
return r
# --- the approval mail branches ------------------------------------------------------------------
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
_approve(as_user(admin), inv.id)
(email, _lang, link, has_account, hours), _ = sent[0]
assert email == "waiting@example.com" and has_account is True
assert link and "#verify=" in link
# The whole point: this is the ONLY message they now have to open.
assert hours == 24 * 7
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
token = sent[0][0][2].split("#verify=")[1]
r = as_user(admin).post("/auth/verify", json={"token": token})
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).email_verified is True
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
# before it is read, and the one-mail promise with it.
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="ready@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="ready@example.com", password=_PW, is_active=False)
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
assert link is None and has_account is True # no link to offer, and none needed
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="pending")
db.add(inv)
db.commit()
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
# noticeable — the failure that went unnoticed on 2026-09-03.
assert link is None and has_account is False
# --- the deliberate invitation --------------------------------------------------------------------
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
approved = _capture(monkeypatch, "send_access_approved")
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
# asked to be nudged.
assert r.status_code == 200 and r.json()["invited_at"] is None
assert approved == [] and invited == []
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 200 and r.json()["invited_at"] is not None
assert invited[0][0][0] == "maybe@example.com"
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
# made — the wrong answer means mailing a real person twice.
db.expire_all()
assert db.get(Invite, inv.id).invited_at is not None
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="asking@example.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
db.expire_all()
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
# --- the silent register no-op --------------------------------------------------------------------
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
assert (
client.post(
"/auth/register", json={"email": "twice@example.com", "password": _PW}
).status_code
== 200
)
assert len(sent) == 1
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
# was ever sent. The response stays uniform; only the dead end is gone.
assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
assert len(sent) == 2
assert sent[1][0][0] == "twice@example.com"
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_verify_email")
for _ in range(2):
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
db.expire_all()
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
assert len(rows) == 1
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
make_user(email="done@example.com", password=_PW) # already verified
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
# to someone who already did would be a lie.
assert r.status_code == 200
assert sent == []