"""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): assert ( client.post( "/auth/register", json={"email": "twice@example.com", "password": _PW} ).status_code == 200 ) 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 == []