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>
228 lines
10 KiB
Python
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())
|