The funnel's common ordering made a person open TWO messages: one saying their access was approved, another asking them to confirm their address. The approval mail now branches on what they actually still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse than a plain one: telling someone to sign in while their address is unconfirmed contradicts the screen that will refuse them. That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval — it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in and sets no password. The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here too, and it was never a delivery failure: re-registering an address that already had an unconfirmed account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend already does exactly this for the same input. Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a possibility — usually already agreed with the person — and a row can sit approved for weeks with nobody registering; a mail fired automatically there would be nudging someone who never asked. Telling them is its own act on its own button, `invited_at` records it so a second click is a decision rather than an accident, and the button only appears where nobody is behind the address yet: "the door is open, come in" is meaningless to someone already inside. Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may sign in, and this account still can't", which leaves an undecided request with the queue that owns it. And the invitation button was offered on every approved row, half of which are existing users. Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail always has, while this one left the reader to discover both by meeting a dead link. The card's "didn't get the email?" moves up under the form, and the three waiting messages name the spam folder, which is where a first message to a fresh recipient sometimes lands. Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a preference nobody recorded cannot be backfilled later. UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed nobody; the invitation recorded itself and the row then warned that sending again would write twice. Test data removed.
546 lines
23 KiB
Python
546 lines
23 KiB
Python
"""Auth / session security core (TEST-1), DB-lane.
|
|
|
|
The wallet impersonation gate, epoch-based session revocation, single-use tokens and the
|
|
enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was
|
|
covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole
|
|
browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user
|
|
override) so login, the wallet gate and epoch revoke are the code under test.
|
|
|
|
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
|
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.auth import ACTIVE_ACCOUNT_HEADER
|
|
from app.main import app
|
|
|
|
_PW = "correct horse battery"
|
|
|
|
# The `client`/`as_user` fixtures bake in the post-setup `_configured` state, so no per-test patch here.
|
|
|
|
|
|
def _login(client, email, password):
|
|
return client.post("/auth/password-login", json={"email": email, "password": password})
|
|
|
|
|
|
def test_password_login_then_me_is_authenticated(client, make_user):
|
|
make_user(email="alice@example.com", password=_PW)
|
|
assert _login(client, "alice@example.com", _PW).status_code == 200
|
|
me = client.get("/api/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["authenticated"] is True
|
|
assert me.json()["email"] == "alice@example.com"
|
|
|
|
|
|
def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make_user):
|
|
make_user(email="bob@example.com", password=_PW)
|
|
wrong_pw = _login(client, "bob@example.com", "not the password")
|
|
unknown = _login(client, "nobody@example.com", _PW)
|
|
assert wrong_pw.status_code == 401 and unknown.status_code == 401
|
|
# Identical body: a wrong password and a non-existent account must be indistinguishable (no
|
|
# account-enumeration oracle).
|
|
assert wrong_pw.json() == unknown.json()
|
|
|
|
|
|
def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user):
|
|
# The X-Siftlode-Account header may only re-target an account already signed into THIS browser
|
|
# (in the session wallet). A header naming someone else must be ignored, not honored — otherwise
|
|
# it's an impersonation gate hole.
|
|
make_user(email="alice2@example.com", password=_PW)
|
|
mallory = make_user(email="mallory@example.com", password=_PW)
|
|
assert _login(client, "alice2@example.com", _PW).status_code == 200
|
|
me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)})
|
|
assert me.status_code == 200
|
|
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
|
|
|
|
|
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
|
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
|
|
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
|
|
# is re-stamped and stays in. Checked on /api/feed (a protected endpoint that 401s on revocation).
|
|
make_user(email="carol@example.com", password=_PW)
|
|
# A second, independent cookie jar; the `client` fixture already routed get_db to the test DB on
|
|
# the shared app, so this instance uses it too — it just has its own session cookie.
|
|
other = TestClient(app)
|
|
assert _login(client, "carol@example.com", _PW).status_code == 200
|
|
assert _login(other, "carol@example.com", _PW).status_code == 200
|
|
assert other.get("/api/feed").status_code == 200 # both valid to start
|
|
|
|
assert client.post("/auth/logout-others").status_code == 200
|
|
assert other.get("/api/feed").status_code == 401 # its epoch is now stale
|
|
assert client.get("/api/feed").status_code == 200 # the caller stays signed in
|
|
|
|
|
|
def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_user, db):
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(email="dave@example.com", password=_PW)
|
|
assert _login(client, "dave@example.com", _PW).status_code == 200
|
|
assert client.get("/api/feed").status_code == 200 # session valid at the pre-reset epoch
|
|
|
|
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
|
db.commit()
|
|
ok = client.post(
|
|
"/auth/password-reset/confirm", json={"token": raw, "password": "brand new pw!"}
|
|
)
|
|
assert ok.status_code == 200
|
|
|
|
# A reset bumps the epoch (compromise response) -> the pre-reset cookie is now rejected.
|
|
assert client.get("/api/feed").status_code == 401
|
|
# The token is single-use: a replay is a clean 400, not a second reset.
|
|
replay = client.post(
|
|
"/auth/password-reset/confirm", json={"token": raw, "password": "another pw!!"}
|
|
)
|
|
assert replay.status_code == 400
|
|
|
|
|
|
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
|
|
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
|
|
# unverified (SEC-10, account pre-emption). That was redundant: password_login refuses an
|
|
# unverified account BEFORE it reads is_active, so approval hands a pre-emptor's password nothing
|
|
# — see the pair of tests below. All it did was destroy the password a legitimate registrant had
|
|
# just chosen, in what is the NORMAL ordering (the admin decides before the user reads their mail).
|
|
from app.routes.admin import _activate_user_by_email
|
|
|
|
user = make_user(
|
|
email="approved-first@example.com",
|
|
password="chosen at registration",
|
|
email_verified=False,
|
|
is_active=False,
|
|
)
|
|
epoch0 = user.session_epoch or 0
|
|
|
|
_activate_user_by_email(db, "approved-first@example.com")
|
|
|
|
assert user.is_active is True
|
|
assert user.password_hash is not None
|
|
assert user.password_revoked_at is None
|
|
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
|
|
|
|
|
|
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
|
|
# Why dropping the approval-path revoke is safe: this is the gate that actually stops a
|
|
# pre-emptor, and it runs whatever the admin decided.
|
|
make_user(
|
|
email="notyet@example.com",
|
|
password="attacker-chosen",
|
|
email_verified=False,
|
|
is_active=True,
|
|
)
|
|
|
|
r = _login(client, "notyet@example.com", "attacker-chosen")
|
|
|
|
assert r.status_code == 403
|
|
assert r.json()["detail"]["reason"] == "email_unverified"
|
|
|
|
|
|
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
|
|
# The payoff for the legitimate registrant: the password chosen at registration still works once
|
|
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(
|
|
email="ordering@example.com",
|
|
password="chosen at registration",
|
|
email_verified=False,
|
|
is_active=False,
|
|
)
|
|
from app.routes.admin import _activate_user_by_email
|
|
|
|
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
|
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
|
db.commit()
|
|
|
|
verified = client.post("/auth/verify", json={"token": raw})
|
|
|
|
assert verified.status_code == 200
|
|
assert verified.json() == {
|
|
"ok": True,
|
|
"password_reset_required": False,
|
|
"email": "ordering@example.com",
|
|
}
|
|
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
|
|
|
|
|
|
def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
|
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
|
|
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
|
|
from app.routes.admin import _activate_user_by_email
|
|
|
|
user = make_user(
|
|
email="legit@example.com", password="my own password", email_verified=True, is_active=False
|
|
)
|
|
_activate_user_by_email(db, "legit@example.com")
|
|
assert user.is_active is True
|
|
assert user.password_hash is not None
|
|
|
|
|
|
def _register_and_fetch(db, email: str):
|
|
"""Run the /register background worker, then read the users/invites rows back through a FRESH
|
|
query. The worker owns its own session, so anything it failed to commit is simply not there."""
|
|
from sqlalchemy import select as _select
|
|
|
|
from app.models import Invite, User
|
|
from app.routes.auth import _register_account
|
|
|
|
_register_account(email, _PW)
|
|
db.expire_all()
|
|
user = db.execute(_select(User).where(User.email == email)).scalar_one_or_none()
|
|
invite = db.execute(_select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
|
return user, invite
|
|
|
|
|
|
def _no_smtp(monkeypatch):
|
|
"""Put the worker on the no-SMTP path and capture the admin notice instead of sending it."""
|
|
from app.routes import auth as auth_mod
|
|
|
|
notified: list[str] = []
|
|
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
|
|
monkeypatch.setattr(
|
|
auth_mod.email_mod, "send_admin_new_request", lambda admins, to: notified.append(to) or True
|
|
)
|
|
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"
|
|
# The notify path still fires for an already-approved address — nothing lands in the queue, so
|
|
# it is the only announcement there is. (Whether it is DELIVERED is separate: on a real no-SMTP
|
|
# instance `_send` drops it, which is the open half of the problem, roadmap UX-25.)
|
|
assert notified == ["roland@example.com"]
|
|
|
|
|
|
def test_registration_with_an_already_pending_request_persists_the_account(
|
|
db, make_user, monkeypatch
|
|
):
|
|
# 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)
|
|
make_user(email="admin@example.com", role="admin")
|
|
db.add(Invite(email="pia@example.com", status="pending"))
|
|
db.commit()
|
|
|
|
user, invite = _register_and_fetch(db, "pia@example.com")
|
|
|
|
assert user is not None, "registration was rolled back — the account is gone"
|
|
assert user.is_active is False
|
|
assert invite.status == "pending"
|
|
assert notified == ["pia@example.com"]
|
|
|
|
|
|
def test_an_admin_can_activate_a_pre_approved_registrant(db, 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.
|
|
from app.models import Invite
|
|
from app.routes.admin import _activate_user_by_email
|
|
|
|
_no_smtp(monkeypatch)
|
|
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
|
|
db.commit()
|
|
user, _ = _register_and_fetch(db, "roland@example.com")
|
|
|
|
_activate_user_by_email(db, "roland@example.com")
|
|
|
|
db.expire_all()
|
|
assert user.is_active is True
|
|
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
|
|
|
|
|
|
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
|
|
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 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 == ["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 (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)
|
|
make_user(email="admin@example.com", role="admin")
|
|
db.add(Invite(email="denis@example.com", status="denied", decided_by="admin@example.com"))
|
|
db.commit()
|
|
|
|
user, invite = _register_and_fetch(db, "denis@example.com")
|
|
|
|
assert user is not None
|
|
assert user.is_active is False
|
|
assert invite.status == "pending"
|
|
assert invite.decided_at is None and invite.decided_by is None
|
|
assert notified == ["denis@example.com"]
|
|
|
|
|
|
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.
|
|
from app.models import Invite
|
|
from app.routes import auth as auth_mod
|
|
|
|
sent: list[str] = []
|
|
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
|
|
monkeypatch.setattr(
|
|
auth_mod.email_mod, "send_verify_email", lambda to, link, **kw: sent.append(to)
|
|
)
|
|
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, to: True)
|
|
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
|
db.commit()
|
|
|
|
user, invite = _register_and_fetch(db, "rita@example.com")
|
|
|
|
assert user is not None
|
|
assert user.email_verified is False # must confirm the address
|
|
assert user.is_active is False
|
|
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. 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="approved", decided_by="admin@example.com"))
|
|
db.commit()
|
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
|
|
|
assert client.post("/auth/verify", json={"token": raw}).status_code == 200
|
|
|
|
db.expire_all()
|
|
assert user.email_verified is True
|
|
assert user.is_active is False
|
|
assert _login(client, "rita@example.com", _PW).status_code == 403
|
|
|
|
|
|
def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
|
|
# The tests above call _register_account directly, so nothing covered the ROUTE: its
|
|
# allow_registration gate, its rate limiter and the background.add_task wiring that makes the
|
|
# whole flow reachable at all. TestClient runs BackgroundTasks after the response, so a single
|
|
# POST exercises the real path end to end.
|
|
from sqlalchemy import select as _select
|
|
|
|
from app.models import User
|
|
|
|
_no_smtp(monkeypatch)
|
|
|
|
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
|
|
|
|
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
|
db.expire_all()
|
|
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
|
assert user is not None, "POST /auth/register answered ok but created nothing"
|
|
assert user.password_hash is not None
|
|
|
|
|
|
def test_register_route_is_refused_when_registration_is_closed(client, db, monkeypatch):
|
|
# The gate is an explicit 403 with nothing created — not a uniform "ok" that silently drops it.
|
|
from sqlalchemy import select as _select
|
|
|
|
from app import sysconfig
|
|
from app.models import User
|
|
|
|
_no_smtp(monkeypatch)
|
|
sysconfig.set_value(db, "allow_registration", False)
|
|
|
|
r = client.post("/auth/register", json={"email": "closed@example.com", "password": _PW})
|
|
|
|
assert r.status_code == 403
|
|
db.expire_all()
|
|
assert (
|
|
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
|
|
is None
|
|
)
|
|
|
|
|
|
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
|
|
#
|
|
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
|
|
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
|
|
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
|
|
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
|
|
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
|
|
# these pin each step of the way back in.
|
|
#
|
|
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
|
|
# this state — plus every account stripped on prod before that change, which is why the whole recovery
|
|
# route below must keep working. These tests manufacture the state through the surviving producer.
|
|
|
|
|
|
def _revoke_as_google_adoption(db, user) -> None:
|
|
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
|
|
Google callback adopting an account whose email was never verified."""
|
|
from app.routes.auth import revoke_preset_password_if_unverified
|
|
|
|
revoke_preset_password_if_unverified(user)
|
|
db.commit()
|
|
|
|
|
|
def _capture_reset_links(monkeypatch) -> list[str]:
|
|
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
|
from app.routes import auth as auth_mod
|
|
|
|
sent: list[str] = []
|
|
monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to))
|
|
return sent
|
|
|
|
|
|
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
|
|
# The guard still fires where it earns its keep: the real owner has just proven ownership through
|
|
# Google, so a password set on the address while it was unverified must not survive.
|
|
victim = make_user(
|
|
email="stamped@example.com",
|
|
password="attacker-chosen",
|
|
email_verified=False,
|
|
is_active=False,
|
|
)
|
|
assert victim.password_revoked_at is None
|
|
epoch0 = victim.session_epoch or 0
|
|
|
|
_revoke_as_google_adoption(db, victim)
|
|
|
|
assert victim.password_hash is None
|
|
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
|
|
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
|
|
|
|
|
|
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
|
|
db, make_user, monkeypatch
|
|
):
|
|
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
|
|
from app.routes.auth import _send_reset_if_eligible
|
|
|
|
victim = make_user(
|
|
email="lockedout@example.com",
|
|
password="chosen at registration",
|
|
email_verified=False,
|
|
is_active=False,
|
|
)
|
|
_revoke_as_google_adoption(db, victim)
|
|
sent = _capture_reset_links(monkeypatch)
|
|
|
|
_send_reset_if_eligible("lockedout@example.com")
|
|
|
|
assert sent == ["lockedout@example.com"]
|
|
|
|
|
|
def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkeypatch):
|
|
# The other side of the rule: an account that NEVER had a password gets no reset link — its owner
|
|
# adds one from Settings while signed in (/auth/set-password), which proves identity by session.
|
|
from app.routes.auth import _send_reset_if_eligible
|
|
|
|
make_user(email="googler@example.com", password=None, google_sub="sub-123")
|
|
sent = _capture_reset_links(monkeypatch)
|
|
|
|
_send_reset_if_eligible("googler@example.com")
|
|
|
|
assert sent == []
|
|
|
|
|
|
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(
|
|
email="recovered@example.com",
|
|
password="chosen at registration",
|
|
email_verified=False,
|
|
is_active=True, # already approved; reset-confirm verifies the email but never activates
|
|
)
|
|
_revoke_as_google_adoption(db, user)
|
|
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
|
db.commit()
|
|
|
|
ok = client.post(
|
|
"/auth/password-reset/confirm", json={"token": raw, "password": "a brand new one"}
|
|
)
|
|
|
|
assert ok.status_code == 200
|
|
db.expire_all()
|
|
assert user.password_hash is not None
|
|
assert user.password_revoked_at is None # spent — not a permanently reset-eligible account
|
|
assert _login(client, "recovered@example.com", "a brand new one").status_code == 200
|
|
|
|
|
|
def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db, make_user):
|
|
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
|
|
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
|
|
# password" and has no way to learn why the password they chose stopped working.
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(
|
|
email="needspw@example.com",
|
|
password="chosen at registration",
|
|
email_verified=False,
|
|
is_active=False,
|
|
)
|
|
_revoke_as_google_adoption(db, user)
|
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
|
db.commit()
|
|
|
|
r = client.post("/auth/verify", json={"token": raw})
|
|
|
|
assert r.status_code == 200
|
|
assert r.json() == {
|
|
"ok": True,
|
|
"password_reset_required": True,
|
|
"email": "needspw@example.com",
|
|
}
|
|
|
|
|
|
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
|
|
from app.routes.auth import _issue_token
|
|
|
|
user = make_user(email="intact@example.com", email_verified=False, is_active=False)
|
|
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
|
db.commit()
|
|
|
|
r = client.post("/auth/verify", json={"token": raw})
|
|
|
|
assert r.status_code == 200
|
|
assert r.json() == {
|
|
"ok": True,
|
|
"password_reset_required": False,
|
|
"email": "intact@example.com",
|
|
}
|