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 sat empty, the tab badge read zero, and the only working move was re-typing the address into the whitelist box. Nobody would guess that, and on 2026-09-03 nobody did. This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY: auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the address; they just had no way to know they had to. The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference between the owner and whoever registered the address first (SEC-15), and an admin about to let someone in should see it. A person the queue above already covers is deliberately NOT listed here; two buttons for one person is worse than one. Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the button that appears and the badge that counts cannot disagree. Without that gate it would manufacture 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. Letting in someone with no approved invite stays the whitelist box's job, which says so out loud. The badge counts activations SEPARATELY from pending invites rather than widening an existing field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on. `send_verification_link` is now one function with three callers (registration, the public resend, this one), so the link's shape and TTL cannot drift by who triggered it. UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then register), the badge counting it, Activate letting them in and dropping the badge, the row losing its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the audit log. Test data removed.
187 lines
8.4 KiB
Python
187 lines
8.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. Two buttons for one
|
|
# person is worse than one.
|
|
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_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, link: sent.append((to, link))
|
|
)
|
|
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 (2026-09-03: the first
|
|
# message to a fresh Gmail landed in Spam), so it is theirs to help with.
|
|
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
|