Files
FabledSteward/tests/integration/test_passwords.py
T
bvandeusenandClaude Opus 5.5 1ea86c0330
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m45s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m21s
test: a reader's zone comes from their account; a London expiry reads BST or GMT (#6350)
The change-form test set the zone on the session, which the auth middleware
resets from the account on every request. The password reset test asserted
BST, which stopped being London's zone name on 25 Oct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:39:14 -04:00

228 lines
10 KiB
Python

"""Integration: local users manage their own password (milestone 497): change
it on the Account page, or reset it from an emailed link. The routes run
for real against Postgres; only mail delivery is replaced."""
from __future__ import annotations
import asyncio
import json
import os
import re
import time
import uuid
import bcrypt
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
from steward.auth.throttle import login_throttle
from steward.auth.throttle import reset_throttle
for t in (login_throttle, reset_throttle):
t._failures.clear() # wrong passwords and reset requests below count against this address
app = create_app(testing=False)
yield app
for t in (login_throttle, reset_throttle):
t._failures.clear()
async def _local(app, password="old password", source="local"):
"""A local account with a real password, and a browser signed in as it."""
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
name = f"pw-{uid[:8]}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@example.com", role=UserRole.viewer,
password_hash=bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(),
is_active=True, auth_source=source, time_zone="Europe/London"))
return uid, name, await _signed_in(app, uid, name)
async def _signed_in(app, uid, name, ago=60):
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=name, _csrf="test-token",
login_at=time.time() - ago)
return client
async def _user(app, uid):
from steward.models.users import User
async with app.db_sessionmaker() as s:
return await s.get(User, uid)
async def _audits(app, uid):
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def _checks(user, password):
return bcrypt.checkpw(password.encode(), user.password_hash.encode())
def test_a_local_user_changes_their_password_and_their_other_browsers_sign_out(app):
async def _go():
uid, name, me = await _local(app)
elsewhere = await _signed_in(app, uid, name, ago=120)
page = await (await me.get("/account/")).get_data(as_text=True)
assert 'name="current_password"' in page and 'name="new_password"' in page
keep = {"time_zone": "Europe/London", "email": f"{name}@example.com"}
# Blank: the rest saves and the password stays.
resp = await me.post("/account/", headers=CSRF, form=keep | {"current_password": "old password"})
assert resp.headers["Location"] == "/account/?done=saved"
assert _checks(await _user(app, uid), "old password")
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "wrong one", "new_password": "new password", "new_confirm": "new password"})
assert resp.status_code == 400 and "current password isn" in await resp.get_data(as_text=True)
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "old password", "new_password": "new password", "new_confirm": "other"})
assert resp.status_code == 400 and "passwords don" in await resp.get_data(as_text=True)
assert _checks(await _user(app, uid), "old password")
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "old password", "new_password": "new password", "new_confirm": "new password"})
assert resp.headers["Location"] == "/account/?done=password"
assert _checks(await _user(app, uid), "new password")
page = await (await me.get(resp.headers["Location"])).get_data(as_text=True)
assert "Your password is changed" in page # still signed in here
assert (await elsewhere.get("/account/")).status_code == 302 # signed out there
assert "account.password_changed" in [a for a, _ in await _audits(app, uid)]
asyncio.run(_go())
def test_an_sso_account_has_no_password_to_change(app):
async def _go():
uid, name, me = await _local(app, source="oidc")
page = await (await me.get("/account/")).get_data(as_text=True)
assert 'name="new_password"' not in page and "changed there, not in Steward" in page
resp = await me.post("/account/", headers=CSRF, form={
"time_zone": "Europe/London", "email": f"{name}@example.com",
"current_password": "old password", "new_password": "new password", "new_confirm": "new password"})
assert resp.status_code == 302 and _checks(await _user(app, uid), "old password")
asyncio.run(_go())
_KEY = re.compile(r"/reset/([A-Za-z0-9_-]{20,})")
def _mail(app, monkeypatch):
from tests.integration._mail import capture
return capture(app, monkeypatch)
async def _deliver(app):
"""The outbox sender's next tick: a reset email is queued (#5508)."""
from steward.core import outbox
await outbox.send_due(app)
async def _stranger(app):
client = app.test_client()
await client.get("/login")
async with client.session_transaction() as sess:
sess["_csrf"] = "test-token"
return client
def test_a_forgotten_password_is_reset_from_the_emailed_link(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
uid, name, elsewhere = await _local(app)
guest = await _stranger(app)
page = await (await guest.get("/login")).get_data(as_text=True)
assert 'href="/forgot"' in page
# The same answer for a stranger's name, an SSO account and a real one.
sso, sso_name, _ = await _local(app, source="oidc")
answers = []
for who in ("nobody-" + uid[:8], sso_name, f"{name}@EXAMPLE.com"):
resp = await guest.post("/forgot", headers=CSRF, form={"name": who})
answers.append((resp.status_code, "on its way" in await resp.get_data(as_text=True)))
assert answers == [(200, True)] * 3
await _deliver(app)
from tests.integration._mail import to
mine = to(sent, f"{name}@example.com", f"{sso_name}@example.com", f"nobody-{uid[:8]}@example.com")
assert [m["To"] for m in mine] == [f"{name}@example.com"]
text = mine[0].get_body(("plain",)).get_content()
# In the account's zone, not UTC: London's name for it, summer or winter.
assert name in text and re.search(r"\d\d:\d\d (BST|GMT)\b", text)
key = _KEY.search(text).group(1)
page = await (await guest.get(f"/reset/{key}")).get_data(as_text=True)
assert "Choose a new password" in page and name in page
resp = await guest.post(f"/reset/{key}", headers=CSRF, form={"password": "brand new pw", "confirm": "nope"})
assert resp.status_code == 400 and "passwords don" in await resp.get_data(as_text=True)
resp = await guest.post(f"/reset/{key}", headers=CSRF, form={"password": "brand new pw", "confirm": "brand new pw"})
assert resp.status_code == 302
assert _checks(await _user(app, uid), "brand new pw")
async with guest.session_transaction() as sess:
assert sess["user_id"] == uid # signed straight in
assert (await guest.get("/account/")).status_code == 200
assert (await elsewhere.get("/account/")).status_code == 302 # the old browser is out
assert [a for a, _ in await _audits(app, uid)] == [
"user.password_reset_requested", "user.password_reset_by_email"]
again = await _stranger(app)
resp = await again.get(f"/reset/{key}")
assert resp.status_code == 404 and "expired or been used" in await resp.get_data(as_text=True)
resp = await again.post(f"/reset/{key}", headers=CSRF, form={"password": "third one!", "confirm": "third one!"})
assert resp.status_code == 404 and _checks(await _user(app, uid), "brand new pw")
asyncio.run(_go())
def test_asking_again_kills_the_first_link_and_an_old_link_expires(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
from datetime import datetime, timedelta, timezone
from sqlalchemy import update
from steward.models.password_resets import PasswordReset
uid, name, _ = await _local(app)
guest = await _stranger(app)
for _ in range(2):
await guest.post("/forgot", headers=CSRF, form={"name": name})
await _deliver(app)
from tests.integration._mail import to
first, second = (_KEY.search(m.get_body(("plain",)).get_content()).group(1)
for m in to(sent, f"{name}@example.com"))
assert (await guest.get(f"/reset/{first}")).status_code == 404
assert (await guest.get(f"/reset/{second}")).status_code == 200
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(PasswordReset).where(PasswordReset.user_id == uid)
.values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
assert (await guest.get(f"/reset/{second}")).status_code == 404
asyncio.run(_go())
def test_without_mail_the_forgot_page_says_to_ask_an_admin(app):
async def _go():
app.config["SMTP"] = {}
guest = await _stranger(app)
assert 'href="/forgot"' not in await (await guest.get("/login")).get_data(as_text=True)
page = await (await guest.get("/forgot")).get_data(as_text=True)
assert "Ask an admin" in page and 'name="name"' not in page
asyncio.run(_go())