"""What a dead email link says, and what it costs to ask, DB-lane. Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full "set a new password" form — because a `#reset=` fragment was present, nothing more — and only after the user typed a password did the server answer 400. No security hole (the account's tokens cascade away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly the kind this epic exists to remove. The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their one real attempt. That is the property most worth pinning here. """ from app.routes.auth import RESET_TTL, _issue_token _PW = "correct horse battery" _NEW = "brand new password" def _issue(db, user): raw = _issue_token(db, user, "reset", RESET_TTL) db.commit() return raw def test_checking_a_live_link_does_not_spend_it(client, db, make_user): raw = _issue(db, make_user(email="live@example.com", password=_PW)) assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True} assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True} # The whole point: after any number of checks the one real attempt is still there. assert client.post( "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} ).json() == {"ok": True} def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user): user = make_user(email="gone@example.com", password=_PW) raw = _issue(db, user) db.delete(user) db.commit() assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False} # And the confirm agrees, in the structured shape the card renders as a named screen — the two # must never disagree about one token. r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW}) assert r.status_code == 400 assert r.json()["detail"] == {"code": "reset", "reason": "invalid"} def test_a_spent_link_is_reported_dead(client, db, make_user): raw = _issue(db, make_user(email="spent@example.com", password=_PW)) assert ( client.post( "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} ).status_code == 200 ) assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False} def test_a_too_short_password_does_not_burn_the_link(client, db, make_user): raw = _issue(db, make_user(email="short@example.com", password=_PW)) r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"}) assert r.status_code == 400 assert r.json()["detail"]["reason"] == "weak_password" # A mistyped password must not cost the link — the length check runs BEFORE the token is spent, # and the card tells the two failures apart by `reason` so only one of them takes the form down. assert client.post( "/auth/password-reset/confirm", json={"token": raw, "password": _NEW} ).json() == {"ok": True} def test_a_dead_verification_link_answers_in_the_same_structured_shape(client): r = client.post("/auth/verify", json={"token": "no-such-token"}) assert r.status_code == 400 assert r.json()["detail"] == {"code": "verify", "reason": "invalid"} def test_the_check_never_reports_a_live_link_dead_when_throttled( client, db, make_user, monkeypatch ): from app.routes import auth as auth_routes raw = _issue(db, make_user(email="throttled@example.com", password=_PW)) monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False) # Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead" # would lock someone out of a link that works. assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}