Files
siftlode/backend/tests/test_review_round.py
T
peter 563fa0513d fix(auth): close what the epic's own review found (E9.5 review round 1)
Eleven findings survived adversarial verification; these are the fixes.

**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.

**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.

**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.

**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.

**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.

**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
2026-09-04 22:42:15 +02:00

318 lines
14 KiB
Python

"""E9.5 review round — the invariants the epic's own review found unpinned.
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
covered only while the address stayed unverified.
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import auth as auth_core
from app import email as email_mod
from app import mailtext
from app.config import settings
from app.models import AuthToken, Invite, User
from app.routes import auth as auth_routes
_PW = "correct horse battery"
@pytest.fixture
def wire(monkeypatch):
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
never renders in a test is a body that first renders in someone's inbox."""
out: list[tuple[list[str], str, str]] = []
monkeypatch.setattr(
email_mod,
"_send",
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
)
return out
@pytest.fixture
def smtp_on(monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
# --- every mail actually renders ------------------------------------------------------------------
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
# that executed their `.format()` at all.
_EVERY_MAIL = [
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
("send_access_denied", ("to@example.com", "LANG")),
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
("send_role_changed", ("to@example.com", "LANG", "user", None)),
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_deleted", ("to@example.com", "LANG", None)),
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_suspended", ("to@example.com", "LANG", None)),
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
("send_test", ("to@example.com", "LANG")),
]
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
def test_every_mail_renders_in_every_language(wire, name, args, lang):
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
(_, subject, body) = wire[0]
assert subject and body
# A template that lost its interpolation renders the brace text verbatim.
assert "{" not in subject and "{" not in body
def test_a_weeks_link_is_described_in_days_not_hours(wire):
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
assert "24 óra" in wire[1][2]
# --- the pre-emption guard: while unverified, the newest registration owns the account -------------
def test_registering_again_takes_the_account_back_from_a_pre_emptor(db, client, smtp_on, wire):
"""The hole E9.5 S1's argument left open, and the guard that replaces it.
S1 stopped admin approval revoking a preset password, reasoning that `password_login` refuses an
unverified account before it reads `is_active`. True — but S6's approval mail exists to get the
real owner to confirm the address, and that click opens the gate for whatever password is on the
row. So the owner must be able to take the row back, and re-registering is how.
"""
client.post("/auth/register", json={"email": "victim@example.com", "password": "pre-emptor pw"})
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
squatted = user.password_hash
assert squatted is not None
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the owner's pw"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
assert user.password_hash != squatted
# One account, not two — the canonical pre-check still holds.
assert (
len(db.execute(select(User).where(User.email_canonical == "victim@example.com")).all()) == 1
)
def test_the_pre_emptors_verify_link_dies_with_their_password(db, client, smtp_on, wire):
client.post("/auth/register", json={"email": "victim@example.com", "password": "pre-emptor pw"})
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
assert len(first) == 1
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the owner's pw"}
)
db.expire_all()
live = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()
# Exactly one token, and not the one already in the pre-emptor's hands: otherwise they could
# confirm the address themselves and re-open the gate for a password that is no longer theirs.
assert len(live) == 1 and live[0].id != first[0]
def test_a_confirmed_account_is_not_re_opened_by_a_later_registration(
db, client, smtp_on, make_user
):
# The guard is scoped to the UNVERIFIED window. Once the address is confirmed the account is its
# owner's, and a stranger posting the form must not touch the password.
user = make_user(email="done@example.com", password=_PW) # already verified
before = user.password_hash
client.post("/auth/register", json={"email": "done@example.com", "password": "not yours"})
db.expire_all()
assert db.get(User, user.id).password_hash == before
# --- the env bootstrap answers the same question as the gate --------------------------------------
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
# env grant. With the env list matched canonically the request never opens a row.
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
assert db.execute(select(Invite)).scalars().all() == []
assert auth_core.is_allowed(db, "bob@gmail.com") is True
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
def test_approving_one_spelling_activates_the_account_registered_under_another(
db, make_user, as_user, smtp_on, wire
):
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
# and the account exists under the other Gmail spelling of the same mailbox.
admin = make_user(email="admin@example.com", role="admin")
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
user.email = "dotless@gmail.com"
inv = Invite(email="dot.less@gmail.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).is_active is True
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
admin = make_user(email="admin@example.com", role="admin")
doomed = make_user(email="pair.one@gmail.com", password=_PW)
make_user(email="pairone@gmail.com", password=_PW)
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
db.commit()
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
assert r.status_code == 200
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
# --- a button an admin pressed must not report a send that did not happen -------------------------
def test_a_failed_invitation_is_not_recorded_as_written(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
db.expire_all()
# The column exists to answer "did I already write to them?", and the UI dims the button and
# warns about a second mail on the strength of it. A dropped send must not make it say yes.
assert db.get(Invite, inv.id).invited_at is None
def test_a_failed_resend_says_so_instead_of_reporting_ok(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
# --- refusals and links that are already dead ------------------------------------------------------
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
user = make_user(email="late@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
r = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
)
assert r.status_code == 400
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
user = make_user(email="mixed@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
# account and an unknown one; only `invalid` was pinned.
make_user(email="real@example.com", password=_PW)
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
unknown = client.post(
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
)
assert real.status_code == unknown.status_code == 429
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
# --- admins hear about a request in their own language --------------------------------------------
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
notices = [w for w in wire if "asking@example.com" in w[1]]
assert len(notices) == 2
by_recipient = {w[0][0]: w[1] for w in notices}
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"hu"
].format(requester="asking@example.com")
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"en"
].format(requester="asking@example.com")
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}