Files
siftlode/backend/tests/test_admin_pending_access.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

204 lines
9.4 KiB
Python

"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
sat locked out, and the badge read zero. The only working move was re-typing the address into the
whitelist box, which nobody would guess.
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
already allowed, and nothing an unauthenticated caller can reach.
"""
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _pending_access(client):
r = client.get("/api/admin/pending-access")
assert r.status_code == 200
return {u["email"]: u for u in r.json()}
def _stuck_registrant(db, make_user, admin):
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
db.commit()
return make_user(email="later@example.com", password=_PW, is_active=False)
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
_stuck_registrant(db, make_user, admin)
row = _pending_access(as_user(admin))["later@example.com"]
assert row["is_active"] is False
assert (
row["can_activate"] is True
) # the address is already allowed — the click just finishes it
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
# They already have a pending invite with Approve on it in the queue above, and their address is
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="normal@example.com", status="pending"))
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
assert "normal@example.com" not in _pending_access(as_user(admin))
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="fresh@example.com", status="pending"))
db.commit()
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
assert "fresh@example.com" not in _pending_access(as_user(admin))
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
assert r.status_code == 200 and r.json()["is_active"] is True
db.expire_all()
assert db.get(User, stuck.id).is_active is True
actions = db.execute(select(AuditLog.action)).scalars().all()
assert (
"user_activate" in actions
) # the admin acted on someone's access; it is not a silent read
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
# only checks is_active and would let them in, while their Google sign-in would be turned away
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
# in"; this one only finishes a decision already made.
admin = make_user(email="admin@example.com", role="admin")
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "not_allowed"
db.expire_all()
assert db.get(User, nobody.id).is_active is False
def test_activating_twice_is_not_an_error(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
client = as_user(admin)
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
db.commit()
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
assert "banned@example.com" not in _pending_access(as_user(admin))
# --- the confirmation resend ---------------------------------------------------------------------
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
db, make_user, as_user, monkeypatch
):
from app.routes import admin as admin_routes
from app.routes import auth as auth_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
sent: list[tuple[str, str]] = []
monkeypatch.setattr(
auth_routes.email_mod,
"send_verify_email",
lambda to, lang, link, **kw: sent.append((to, link)) is None,
)
admin = make_user(email="admin@example.com", role="admin")
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
# with. The approved invite is what puts them on this screen at all: the list is "the address may
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
db.commit()
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
assert [t.kind for t in tokens] == ["verify"]
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
from app.routes import admin as admin_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
# to fall back on. An admin pressing a button gets the failure, not a success toast.
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="fine@example.com", password=_PW)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
# --- the badge -----------------------------------------------------------------------------------
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
db, make_user, client
):
# Signed in for real, not via the as_user override: /api/me resolves its user through
# optional_current_user, which that override does not cover (see test_me_email_flag).
admin = make_user(email="admin@example.com", password=_PW, role="admin")
_stuck_registrant(db, make_user, admin) # approved first → counts
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
assert (
client.post(
"/auth/password-login", json={"email": admin.email, "password": _PW}
).status_code
== 200
)
me = client.get("/api/me").json()
assert me["authenticated"] is True
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
# would be nagging about something the admin did not cause.
assert me["pending_activations"] == 1
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests