fix(auth): drop the invite reopen — keep the fix to the lost account alone
Round 3 of the review killed the reopen from b5a1002: flipping an approved
invite back to pending let an unauthenticated POST /auth/register REVOKE a
standing approval for any whitelisted-but-unclaimed address, which then fails
is_allowed() and locks the real owner out of Google sign-in — the primary way
people get in here. It also re-armed the pre-emption window it was meant to
close, because with no SMTP email_verified is set True and the SEC-10 guard the
design leaned on never fires.
Two attempts at fixing the stranded-account UX in the same change both came back
worse than the wart. What remains is the narrow, real bug: the account was
silently rolled back and is now committed. An admin still binds the approval to
the account, exactly as before this branch. UX-25 in the roadmap records the
follow-up and both dead ends so they are not retried.
Also from round 3: the upsert docstring no longer claims the admin UI cannot
reach these accounts (re-entering the address in the same panel's whitelist box
does, and a test now pins it), the release notes are scoped to what the diff
actually changes with the operator note moved where the dialog renders it, and
RateLimiter has unit tests so reset() is not production code only a fixture
calls.
This commit is contained in:
@@ -156,13 +156,42 @@ def _no_smtp(monkeypatch):
|
||||
return notified
|
||||
|
||||
|
||||
def test_registration_of_a_pre_approved_email_persists_the_account_without_smtp(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE regression. An admin whitelists an address, the person registers, and the account silently
|
||||
# never existed: upsert_pending_invite leaves an already-approved row alone and returns WITHOUT
|
||||
# committing, and the only other commit on this path (_issue_token) is gated on SMTP — so with no
|
||||
# working mail the flushed user was rolled back at session close. The sign-up answered "ok",
|
||||
# created nothing, and re-registering did the same again.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "roland@example.com")
|
||||
|
||||
assert user is not None, "pre-approved registration was rolled back — the account is gone"
|
||||
assert user.password_hash is not None
|
||||
assert user.email_verified is True # no SMTP => ownership can't be proven by link
|
||||
assert (
|
||||
user.is_active is False
|
||||
) # approval is bound to the account by an admin, not by signing up
|
||||
# The admin's standing decision is left exactly as it was — is_allowed() still reads "approved",
|
||||
# so their Google sign-in keeps working and an anonymous sign-up cannot revoke access.
|
||||
assert invite.status == "approved"
|
||||
assert invite.decided_by == "admin@example.com"
|
||||
# …and no "new access request" notice, because there is no fresh decision to make.
|
||||
assert notified == []
|
||||
|
||||
|
||||
def test_registration_with_an_already_pending_request_persists_the_account(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE regression: someone asks for access, then registers before the admin has decided.
|
||||
# upsert_pending_invite leaves the pending row alone and returns WITHOUT committing, and the only
|
||||
# other commit on this path (_issue_token) is gated on SMTP — so on a no-SMTP instance the flushed
|
||||
# user was rolled back at session close. The sign-up answered "ok" and created nothing.
|
||||
# The same rollback, reached the other way: someone uses Request access, then registers before
|
||||
# the admin has decided. upsert_pending_invite leaves the pending row alone and does not commit.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
@@ -173,39 +202,15 @@ def test_registration_with_an_already_pending_request_persists_the_account(
|
||||
user, invite = _register_and_fetch(db, "pia@example.com")
|
||||
|
||||
assert user is not None, "registration was rolled back — the account is gone"
|
||||
assert user.password_hash is not None
|
||||
assert user.is_active is False # the admin has not decided yet
|
||||
assert user.is_active is False
|
||||
assert invite.status == "pending"
|
||||
assert notified == [] # the admin already has this request; don't re-ping on every retry
|
||||
assert notified == [] # the admin already has this request; don't send a second copy
|
||||
|
||||
|
||||
def test_registration_of_a_pre_approved_email_reopens_the_request_for_the_admin(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# An approval granted before any account existed was a decision about the ADDRESS. Binding it to
|
||||
# whoever has now set a password on that address is a human's call, so the invite goes back to
|
||||
# pending — which is also the only way the admin can reach it: an approved invite is absent from
|
||||
# the Access-requests queue and _decide 409s on an already-decided one.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "roland@example.com")
|
||||
|
||||
assert user is not None
|
||||
assert user.is_active is False # no automatic grant — approving it is what activates
|
||||
assert user.email_verified is True # no SMTP => ownership can't be proven by link
|
||||
assert invite.status == "pending"
|
||||
assert invite.decided_at is None and invite.decided_by is None
|
||||
assert notified == ["roland@example.com"]
|
||||
|
||||
|
||||
def test_approving_the_reopened_request_activates_the_account(db, make_user, monkeypatch):
|
||||
# The other half of the loop: the admin clicks Approve and the person can sign in. This is what
|
||||
# keeps the SEC-10 preset-password guard in the path — _activate_user_by_email runs it.
|
||||
def test_an_admin_can_activate_a_pre_approved_registrant(db, make_user, monkeypatch):
|
||||
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
|
||||
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
|
||||
# _activate_user_by_email, which is also what keeps the SEC-10 preset-password guard in play.
|
||||
from app.models import Invite
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
@@ -218,28 +223,28 @@ def test_approving_the_reopened_request_activates_the_account(db, make_user, mon
|
||||
|
||||
db.expire_all()
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None # verified account keeps its own password
|
||||
assert user.password_hash is not None # a verified account keeps its own password
|
||||
|
||||
|
||||
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
|
||||
db, monkeypatch
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# Guard, not a regression test: this branch always worked (upsert INSERTs the invite and commits
|
||||
# the same transaction). It must keep working — and the account stays inactive, nobody has
|
||||
# approved this address.
|
||||
# the same transaction). It must keep working — and it is the case that DOES notify the admin.
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
|
||||
user, invite = _register_and_fetch(db, "stranger@example.com")
|
||||
|
||||
assert user is not None
|
||||
assert user.is_active is False
|
||||
assert invite is not None and invite.status == "pending"
|
||||
assert notified == [] # no admin in this DB, so nothing to notify
|
||||
assert notified == ["stranger@example.com"]
|
||||
|
||||
|
||||
def test_registration_of_a_previously_denied_email_revives_the_request(db, make_user, monkeypatch):
|
||||
# Guard: a denied person may ask again — the invite flips back to pending so the admin sees a
|
||||
# fresh request. The stale decision must not leak into the new account's state.
|
||||
# Guard: a denied person may ask again — the invite flips back to pending (and commits) so the
|
||||
# admin sees a fresh request. The stale decision must not leak into the new account's state.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
@@ -258,8 +263,7 @@ def test_registration_of_a_previously_denied_email_revives_the_request(db, make_
|
||||
|
||||
def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db, monkeypatch):
|
||||
# Guard, not a regression test: with SMTP on, _issue_token's own commit already persisted the
|
||||
# user, so this branch was never broken and must stay unbroken. The reopened request and the
|
||||
# verification link are independent gates — the account clears both before it can sign in.
|
||||
# user, so this branch was never broken and must stay unbroken.
|
||||
from app.models import Invite
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
@@ -275,18 +279,17 @@ def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db,
|
||||
assert user is not None
|
||||
assert user.email_verified is False # must confirm the address
|
||||
assert user.is_active is False
|
||||
assert invite.status == "pending"
|
||||
assert invite.status == "approved"
|
||||
assert sent == ["rita@example.com"]
|
||||
|
||||
|
||||
def test_verifying_an_email_never_grants_access_on_its_own(client, db, make_user):
|
||||
# Verification attests the address; it does not decide access. An account whose request is still
|
||||
# pending stays inactive and cannot sign in — the two gates are deliberately separate.
|
||||
# Verification attests the address; it does not decide access. The two gates stay separate.
|
||||
from app.models import Invite
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email="rita@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
db.add(Invite(email="rita@example.com", status="pending"))
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user