"""Account, portfolio, member, key and instrument routes. Most of these are ordinary forms, but several enforce rules that only exist because getting them wrong loses something: a portfolio must keep an owner, an API key is shown once and never again, and changing your password signs out every other device. Those are the ones worth reading. """ from __future__ import annotations import re import pytest from sqlalchemy import select import factories as fac from app import auth as auth_mod from app.models import ( HoldingPref, Instrument, Portfolio, PortfolioInvite, PortfolioMember, User, UserSession, ) from test_routes import PASSWORD, make_login, reading, session_csrf HTML = {"accept": "text/html"} def csrf(session_factory) -> str: return session_csrf(session_factory) # --------------------------------------------------------------------------- # # Password # --------------------------------------------------------------------------- # def test_changing_your_password_works_and_keeps_you_signed_in(client, session_factory): make_login(client, session_factory) resp = client.post("/profile/password", data={"current_password": PASSWORD, "password": "a-longer-new-one", "confirm": "a-longer-new-one", "_csrf": csrf(session_factory)}, headers=HTML, follow_redirects=False) assert resp.status_code == 303 assert resp.headers["location"] == "/profile?saved=password" # Still signed in: the page you are on should not log you out. assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200 def test_changing_your_password_signs_out_every_other_device(client, session_factory): """A password change is what you do when you think somebody else has it, so every other session has to die — while this one survives.""" make_login(client, session_factory) token = csrf(session_factory) # ours, before another session exists with session_factory() as s: user = s.scalars(select(User)).one() auth_mod.create_session(s, user, __import__("app.settings", fromlist=["x"]) .PortfolioSettings()) s.commit() with session_factory() as s: assert len(s.scalars(select(UserSession)).all()) == 2 client.post("/profile/password", data={"current_password": PASSWORD, "password": "a-longer-new-one", "confirm": "a-longer-new-one", "_csrf": token}, headers=HTML) with session_factory() as s: remaining = s.scalars(select(UserSession)).all() assert len(remaining) == 1 # only the one that made the change @pytest.mark.parametrize("payload,message", [ ({"current_password": "wrong-password", "password": "a-longer-new-one", "confirm": "a-longer-new-one"}, "Current password is incorrect"), ({"current_password": PASSWORD, "password": "a-longer-new-one", "confirm": "something-else-entirely"}, "new passwords"), ({"current_password": PASSWORD, "password": "short", "confirm": "short"}, "at least 10"), ]) def test_a_bad_password_change_is_refused_with_a_reason(client, session_factory, payload, message): make_login(client, session_factory) resp = client.post("/profile/password", data={**payload, "_csrf": csrf(session_factory)}, headers=HTML) assert message in resp.text def test_a_temporary_password_is_cleared_once_it_is_changed(client, session_factory): make_login(client, session_factory, email="fresh@example.test", must_change=True) client.post("/profile/password", data={"current_password": PASSWORD, "password": "a-longer-new-one", "confirm": "a-longer-new-one", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: assert s.scalars(select(User)).one().must_change_password is False # …and the forced redirect to /profile is gone. assert client.get("/", headers=HTML, follow_redirects=False).status_code == 200 # --------------------------------------------------------------------------- # # Accounts (admin) # --------------------------------------------------------------------------- # def test_deactivating_an_account_signs_it_out_everywhere(client, session_factory): make_login(client, session_factory, admin=True) token = csrf(session_factory) # before another session exists with session_factory() as s: other = fac.make_user(s, "other@example.test") settings = __import__("app.settings", fromlist=["x"]).PortfolioSettings() auth_mod.create_session(s, other, settings) s.commit() other_id = other.id client.post(f"/users/{other_id}/active", data={"_csrf": token}, headers=HTML) with session_factory() as s: assert s.get(User, other_id).is_active is False # A disabled account must not keep a live session lying around. assert s.scalars( select(UserSession).where(UserSession.user_id == other_id)).all() == [] def test_you_cannot_deactivate_yourself(client, session_factory): """Locking the only admin out of their own instance is not a thing the UI should let you do by mis-clicking.""" make_login(client, session_factory, admin=True) with session_factory() as s: me = s.scalars(select(User)).one().id resp = client.post(f"/users/{me}/active", data={"_csrf": csrf(session_factory)}, headers=HTML) assert resp.status_code == 400 def test_toggling_an_unknown_account_is_a_404(client, session_factory): make_login(client, session_factory, admin=True) assert client.post("/users/9999/active", data={"_csrf": csrf(session_factory)}, headers=HTML).status_code == 404 def test_a_duplicate_email_is_refused(client, session_factory): make_login(client, session_factory, admin=True, email="taken@example.test") resp = client.post("/users/add", data={"name": "Someone", "email": "taken@example.test", "password": "a-long-enough-one", "_csrf": csrf(session_factory)}, headers=HTML) assert "already has an account" in resp.text def test_a_new_account_gets_a_portfolio_of_its_own(client, session_factory): """Everyone starts with somewhere to put their own holdings; sharing is opt-in from the other direction.""" make_login(client, session_factory, admin=True) client.post("/users/add", data={"name": "New Person", "email": "new@example.test", "password": "a-long-enough-one", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: new_user = s.scalars(select(User).where(User.email == "new@example.test")).one() memberships = s.scalars( select(PortfolioMember).where(PortfolioMember.user_id == new_user.id)).all() assert [m.role for m in memberships] == ["owner"] assert new_user.must_change_password is True def test_resetting_a_password_forces_a_change_and_signs_them_out(client, session_factory): make_login(client, session_factory, admin=True) token = csrf(session_factory) # before another session exists with session_factory() as s: other = fac.make_user(s, "other@example.test") settings = __import__("app.settings", fromlist=["x"]).PortfolioSettings() auth_mod.create_session(s, other, settings) s.commit() other_id = other.id client.post(f"/users/{other_id}/reset", data={"password": "a-fresh-temporary-one", "_csrf": token}, headers=HTML) with session_factory() as s: assert s.get(User, other_id).must_change_password is True assert s.scalars( select(UserSession).where(UserSession.user_id == other_id)).all() == [] def test_a_short_reset_password_is_refused(client, session_factory): make_login(client, session_factory, admin=True) with session_factory() as s: other = fac.make_user(s, "other@example.test") s.commit() other_id = other.id assert client.post(f"/users/{other_id}/reset", data={"password": "short", "_csrf": csrf(session_factory)}, headers=HTML).status_code == 400 # --------------------------------------------------------------------------- # # Portfolios # --------------------------------------------------------------------------- # def test_creating_a_portfolio_makes_you_its_owner_and_switches_to_it(client, session_factory): make_login(client, session_factory) resp = client.post("/portfolio/new", data={"name": "Family portfolio", "_csrf": csrf(session_factory)}, headers=HTML, follow_redirects=False) assert resp.status_code == 303 with session_factory() as s: created = s.scalars(select(Portfolio).where(Portfolio.name == "Family portfolio")).one() member = s.scalars(select(PortfolioMember).where( PortfolioMember.portfolio_id == created.id)).one() assert member.role == "owner" # The session moved to it, or you would create a portfolio and not be in it. assert s.scalars(select(UserSession).order_by( UserSession.id.desc())).first().active_portfolio_id == created.id def test_a_portfolio_needs_a_name(client, session_factory): make_login(client, session_factory) assert client.post("/portfolio/new", data={"name": " ", "_csrf": csrf(session_factory)}, headers=HTML).status_code == 400 def test_switching_to_a_portfolio_you_are_not_in_is_refused(client, session_factory): """The id comes from a form, so it is checked against your own memberships — a forged value must not move your session into somebody else's holdings.""" make_login(client, session_factory) with session_factory() as s: stranger = fac.make_user(s, "stranger@example.test") theirs = fac.make_portfolio(s, "Not yours", owner=stranger) s.commit() theirs_id = theirs.id resp = client.post("/portfolio/switch", data={"portfolio_id": str(theirs_id), "_csrf": csrf(session_factory)}, headers=HTML) assert resp.status_code == 403 def test_switching_to_one_of_your_own_works(client, session_factory): make_login(client, session_factory) client.post("/portfolio/new", data={"name": "Second", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: first = s.scalars(select(Portfolio).order_by(Portfolio.id)).first().id resp = client.post("/portfolio/switch", data={"portfolio_id": str(first), "_csrf": csrf(session_factory)}, headers=HTML, follow_redirects=False) assert resp.status_code == 303 with session_factory() as s: assert s.scalars(select(UserSession).order_by( UserSession.id.desc())).first().active_portfolio_id == first # --------------------------------------------------------------------------- # # Members # --------------------------------------------------------------------------- # def add_stranger(session_factory, email="friend@example.test") -> int: with session_factory() as s: user = fac.make_user(s, email) s.commit() return user.id def test_a_member_can_be_added_and_given_a_role(client, session_factory): make_login(client, session_factory) friend = add_stranger(session_factory) client.post("/members/add", data={"user_id": str(friend), "role": "viewer", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: member = s.scalars(select(PortfolioMember).where( PortfolioMember.user_id == friend)).one() assert member.role == "viewer" def test_an_unknown_role_is_refused(client, session_factory): make_login(client, session_factory) friend = add_stranger(session_factory) assert client.post("/members/add", data={"user_id": str(friend), "role": "superuser", "_csrf": csrf(session_factory)}, headers=HTML).status_code == 400 def test_adding_an_unknown_account_is_a_404(client, session_factory): make_login(client, session_factory) assert client.post("/members/add", data={"user_id": "9999", "role": "member", "_csrf": csrf(session_factory)}, headers=HTML).status_code == 404 def test_adding_the_same_person_twice_says_so(client, session_factory): make_login(client, session_factory) friend = add_stranger(session_factory) data = {"user_id": str(friend), "role": "member", "_csrf": csrf(session_factory)} client.post("/members/add", data=data, headers=HTML) resp = client.post("/members/add", data=data, headers=HTML, follow_redirects=False) assert "Already+a+member" in resp.headers["location"] def test_a_members_role_can_be_changed(client, session_factory): make_login(client, session_factory) friend = add_stranger(session_factory) client.post("/members/add", data={"user_id": str(friend), "role": "viewer", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: member_id = s.scalars(select(PortfolioMember).where( PortfolioMember.user_id == friend)).one().id client.post(f"/members/{member_id}/role", data={"role": "member", "_csrf": csrf(session_factory)}, headers=HTML) with session_factory() as s: assert s.get(PortfolioMember, member_id).role == "member" def test_a_role_changes_only_when_its_button_is_pressed(client, session_factory): """The select submitted on change: an arrow key moving through it changed somebody's rights at the first press, and without scripting there was no way to submit it at all.""" make_login(client, session_factory) friend = add_stranger(session_factory) client.post("/members/add", data={"user_id": str(friend), "role": "viewer", "_csrf": csrf(session_factory)}, headers=HTML) page = client.get("/members", headers=HTML).text forms = re.findall(r'