tests/test_invite_routes.py
Rama main · réplica de lectura del repositorio original.
"""The invitation flow through the endpoints a browser actually uses.
`test_invites.py` covers the rules; this covers reaching them — that the
landing page is public, that signing up needs an invite, and that a spent link
stops working for the next person to open it.
"""
from __future__ import annotations
import re
import pytest
from sqlalchemy import select
from app import invites
from app.models import PortfolioInvite, PortfolioMember, User
from test_routes import PASSWORD, make_login, pre_auth_csrf, session_csrf
HTML = {"accept": "text/html"}
def _issue(client, session_factory, role: str = "member") -> str:
"""Create an invite through the real endpoint and return its token."""
resp = client.post("/members/invite", follow_redirects=False, headers=HTML,
data={"_csrf": session_csrf(session_factory), "role": role})
return re.search(r"/invite/([A-Za-z0-9_-]+)", resp.text).group(1)
@pytest.fixture
def owner(client, session_factory):
make_login(client, session_factory)
return client
# --------------------------------------------------------------------------- #
# Issuing
# --------------------------------------------------------------------------- #
def test_an_owner_can_issue_a_link_and_it_is_shown_once(owner, session_factory):
"""In the response that made it, and never in an address. It used to
travel in a redirect's query string, and so into browser history and the
proxy's access log, as API keys once did (decisions.md #128). The database
keeps only the hash, so there is nothing to render a second time."""
resp = owner.post("/members/invite", follow_redirects=False, headers=HTML,
data={"_csrf": session_csrf(session_factory), "role": "member"})
token = re.search(r"/invite/([A-Za-z0-9_-]+)", resp.text).group(1)
assert resp.status_code == 200
assert token not in owner.get("/members", headers=HTML).text
assert token not in owner.get("/members?invite=" + token, headers=HTML).text
def test_only_an_owner_can_issue_one(client, session_factory):
make_login(client, session_factory, email="member@example.test", admin=False)
with session_factory() as db:
member = db.scalar(select(User).where(User.email == "member@example.test"))
db.scalar(select(PortfolioMember).where(
PortfolioMember.user_id == member.id)).role = "member"
db.commit()
resp = client.post("/members/invite", follow_redirects=False,
data={"_csrf": session_csrf(session_factory), "role": "owner"})
assert resp.status_code == 403
# --------------------------------------------------------------------------- #
# Accepting without an account
# --------------------------------------------------------------------------- #
def test_the_landing_page_needs_no_account(client, owner, session_factory):
"""The whole point is somebody who has not signed in. The middleware must
let them through, and the page must name what they are joining."""
token = _issue(owner, session_factory)
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
page = client.get(f"/invite/{token}", headers=HTML)
assert page.status_code == 200
assert "Test portfolio" in page.text
def test_signing_up_through_an_invite_creates_the_account_and_the_membership(
client, owner, session_factory
):
token = _issue(owner, session_factory, role="viewer")
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
resp = client.post(f"/invite/{token}/signup", follow_redirects=False,
data={"_csrf": pre_auth_csrf(client), "name": "Joiner",
"email": "joiner@example.test", "password": PASSWORD})
assert resp.headers["location"] == "/"
with session_factory() as db:
joiner = db.scalar(select(User).where(User.email == "joiner@example.test"))
member = db.scalar(select(PortfolioMember).where(
PortfolioMember.user_id == joiner.id))
assert member.role == "viewer"
# Signed in already: being sent back to a login page after creating an
# account is a step nobody needs.
assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200
def test_a_new_account_gets_recovery_codes_it_is_asked_to_save(
client, owner, session_factory
):
"""Issued but not shown, exactly as for an admin-created account: the
banner asks them to save a set of their own on first sign-in. It is what
gets them back in if they ever lose the way they signed up with.
"""
token = _issue(owner, session_factory)
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
client.post(f"/invite/{token}/signup", follow_redirects=False,
data={"_csrf": pre_auth_csrf(client), "name": "Joiner",
"email": "joiner@example.test", "password": PASSWORD})
with session_factory() as db:
joiner = db.scalar(select(User).where(User.email == "joiner@example.test"))
assert joiner.recovery_codes_seen_at is None
assert "no saved recovery codes" in client.get("/", headers=HTML).text
def test_signing_up_without_an_invite_is_not_possible(client, owner, session_factory):
"""The invite is the whole of the authorisation. Without this, the endpoint
is open registration on an app holding somebody's holdings."""
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
resp = client.post("/invite/not-a-real-token/signup", headers=HTML,
follow_redirects=False,
data={"_csrf": pre_auth_csrf(client), "name": "Nobody",
"email": "nobody@example.test", "password": PASSWORD})
assert resp.status_code == 410
with session_factory() as db:
assert db.scalar(select(User).where(
User.email == "nobody@example.test")) is None
def test_an_email_that_already_exists_is_sent_to_sign_in(
client, owner, session_factory
):
token = _issue(owner, session_factory)
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
page = client.post(f"/invite/{token}/signup", headers=HTML,
follow_redirects=False,
data={"_csrf": pre_auth_csrf(client), "name": "Again",
"email": "user@example.test", "password": PASSWORD})
assert "already has an account" in page.text
# Still live: a mistyped email must not burn the invitation.
with session_factory() as db:
assert db.scalar(select(PortfolioInvite)).used_at is None
# --------------------------------------------------------------------------- #
# Accepting with one
# --------------------------------------------------------------------------- #
def test_an_existing_account_joins_without_making_another(
client, owner, session_factory
):
token = _issue(owner, session_factory)
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
# `make_login` creates the account AND a portfolio of its own, which is the
# state that matters here: joining must add access, not replace it.
make_login(client, session_factory, email="other@example.test")
resp = client.post(f"/invite/{token}/accept", follow_redirects=False,
data={"_csrf": session_csrf(session_factory)})
assert resp.headers["location"] == "/"
with session_factory() as db:
joined = db.scalar(select(User).where(User.email == "other@example.test"))
assert len(db.scalars(select(PortfolioMember).where(
PortfolioMember.user_id == joined.id)).all()) == 2
def test_a_spent_link_tells_the_next_person_so(client, owner, session_factory):
token = _issue(owner, session_factory)
owner.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
client.post(f"/invite/{token}/signup", follow_redirects=False,
data={"_csrf": pre_auth_csrf(client), "name": "First",
"email": "first@example.test", "password": PASSWORD})
page = client.get(f"/invite/{token}", headers=HTML)
assert page.status_code == 410
assert "already been used" in page.text
def test_a_withdrawn_link_stops_working(owner, session_factory):
token = _issue(owner, session_factory)
with session_factory() as db:
invite_id = db.scalar(select(PortfolioInvite)).id
owner.post(f"/members/invite/{invite_id}/revoke", follow_redirects=False,
data={"_csrf": session_csrf(session_factory)})
assert owner.get(f"/invite/{token}", headers=HTML).status_code == 410
def test_the_owners_list_shows_what_happened_to_each_one(owner, session_factory):
_issue(owner, session_factory)
page = owner.get("/members", headers=HTML).text
assert ">Live<" in page and "Withdraw" in page
with session_factory() as db:
db.scalar(select(PortfolioInvite)).used_at = invites._utcnow()
db.commit()
page = owner.get("/members", headers=HTML).text
assert ">Used<" in page and "Withdraw" not in page
def test_the_banner_can_be_put_away_for_this_session_only(client, session_factory):
""""Not now" must not mean "never": an account with no saved codes is one
forgotten password from being unreachable, so a new session asks again."""
make_login(client, session_factory)
assert "no saved recovery codes" in client.get("/", headers=HTML).text
client.post("/profile/recovery/later", follow_redirects=False,
data={"_csrf": session_csrf(session_factory)})
assert "no saved recovery codes" not in client.get("/", headers=HTML).text
client.post("/logout", data={"_csrf": session_csrf(session_factory)},
follow_redirects=False)
# Sign in again through the form rather than `make_login`, which would try
# to create the account a second time. A NEW session is the whole point.
client.post("/login", follow_redirects=False, headers=HTML,
data={"email": "user@example.test", "password": PASSWORD,
"_csrf": pre_auth_csrf(client)})
assert "no saved recovery codes" in client.get("/", headers=HTML).text