fix(auth): close the three leftovers E9.5 logged but kept out of its diff

DX-17, three one-liners the epic's canonical sweep did not reach:

`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.

`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.

`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.

DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".

Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.

Gate: vitest 671 -> 676, pytest 924 -> 925.
This commit is contained in:
2026-09-05 01:35:09 +02:00
parent 0cc450d1c9
commit bc9e34d0ae
13 changed files with 198 additions and 22 deletions
+2 -6
View File
@@ -168,9 +168,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
@@ -361,9 +359,7 @@ def delete_invite(
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
if _accounts_for(db, [inv.email]):
raise HTTPException(
status_code=409,
+8 -1
View File
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import state, sysconfig
from app.utils import canonical_email
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
@@ -70,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
raise HTTPException(
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
user = db.execute(
select(User).where(User.email_canonical == canonical_email(email))
).scalar_one_or_none()
if user is None:
user = User(email=email, role="admin")
db.add(user)
+25
View File
@@ -345,3 +345,28 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use
== 200
)
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
person. Resolving by the literal string here would create a SECOND admin row — exactly the
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
test, and the test fixtures present an already-configured instance."""
from app.routes.setup import SetupAdminIn, setup_admin
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
assert len(admins) == 1, [u.email for u in admins]
# The address stays AS TYPED on the row it matched; only identity is canonical.
assert admins[0].email == "a.b.c@gmail.com"
# And the second run's password is the live one — it updated, it did not fork.
from app.security import verify_password
assert verify_password("another good passphrase", admins[0].password_hash)