Files
team-tryouts/tests/test_role_change.py
T
GGThedandClaude Opus 5 d85da32ef5 fix(arch): changement de role sans jeter la session, et en une transaction
ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la
classe d une instance au chargement. edit_user ecrivait donc la colonne par
un UPDATE de niveau instruction puis relisait la ligne -- c est correct.
La suite ne l etait pas.

  db.session.commit()
  db.session.remove()   # discard stale session entirely
  user = User.query.get(user_id_local)

Deux defauts dans ces trois lignes.

1. remove() jette la session entiere. Tout ce que la requete tenait encore
   se retrouvait detache, current_user compris ; le moindre acces a un
   attribut ensuite levait DetachedInstanceError. La route ne survivait
   qu en ayant recopie le nom et l id de l acteur dans des variables
   locales avant -- un contournement, pas la correction. Un expunge de la
   seule instance perimee suffit.

2. Le commit intermediaire coupait l edition en deux. Le role etait acquis
   avant que le reste du formulaire soit applique : une erreur ensuite
   laissait un compte promu et le reste perdu, sans qu aucune interface ne
   le signale. Sans ce commit, l UPDATE reste dans la transaction, la
   relecture le voit, et l ensemble part en un seul commit.

Les evenements d audit passent apres le commit. account.role_changed etait
journalise avant l UPDATE : le journal affirmait un changement que la
transaction pouvait encore annuler.

tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant --
celui de l atomicite ; les cinq autres fixent le comportement qui marchait
deja, pour que la suite de la vague D ne le casse pas.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 14:51:41 -04:00

120 lines
3.9 KiB
Python

"""Changing a user's role — ARCH-008.
`role` is the polymorphic discriminator, and SQLAlchemy fixes an instance's
class at load time. edit_user therefore writes the column with a
statement-level UPDATE and re-reads the row, which is correct. What it did
next was not: it called db.session.remove(), throwing away the whole
session, and it committed the role on its own before the rest of the edit
had been applied.
The two consequences these tests pin down:
- everything the request still held became detached, current_user
included. The route only survived because it had copied the actor's
name and id into locals beforehand — a workaround, in place of the fix.
- the role change was already committed when the rest of the edit ran, so
a failure afterwards left the account promoted and nothing else
applied.
"""
import pytest
from app.extensions import db
from app.models import Coach, Player, User
def _edit(client, user_id, **overrides):
payload = {
'full_name': 'Promoted Person',
'email': '[email protected]',
'role': 'coach',
'is_active_account': 'on',
}
payload.update(overrides)
return client.post(f'/users/{user_id}/edit', data=payload, follow_redirects=True)
class TestPolymorphicIdentity:
def test_the_account_takes_its_new_class(self, app, client, as_role, make_user):
target_id = make_user('player')
as_role('admin')
_edit(client, target_id)
with app.app_context():
assert isinstance(db.session.get(User, target_id), Coach)
def test_the_new_role_grants_its_pages_right_away(
self, app, client, as_role, make_user, login
):
"""isinstance() is how this application authorises; a stale class in
the identity map is a stale permission set."""
target_id = make_user('player')
as_role('admin')
_edit(client, target_id)
with app.app_context():
username = db.session.get(User, target_id).username
client.get('/auth/logout')
login(username)
# Coach-only, and it is a coach's own page rather than a redirect.
assert client.get('/users/notes-dashboard').status_code == 200
def test_the_other_fields_of_the_edit_land_too(
self, app, client, as_role, make_user
):
target_id = make_user('player')
as_role('admin')
_edit(client, target_id, full_name='Renamed Too', phone='555-0100')
with app.app_context():
user = db.session.get(User, target_id)
assert user.full_name == 'Renamed Too'
assert user.phone == '555-0100'
class TestTheActingPresident:
"""db.session.remove() detached current_user mid-request."""
def test_the_president_keeps_their_session(self, client, as_role, make_user):
target_id = make_user('player')
as_role('admin')
_edit(client, target_id)
assert client.get('/users').status_code == 200
def test_the_response_renders(self, client, as_role, make_user):
target_id = make_user('player')
as_role('admin')
response = _edit(client, target_id)
assert response.status_code == 200
class TestAtomicity:
def test_a_failure_after_the_update_leaves_the_role_alone(
self, app, client, as_role, make_user, monkeypatch
):
"""The role used to be committed on its own, before the rest of the
edit ran. A failure afterwards left an account promoted, and no
interface reported it."""
target_id = make_user('player')
as_role('admin')
def _explode(user, selected_games):
raise RuntimeError('storage unavailable')
monkeypatch.setattr('app.routes.users.update_user_gamertags', _explode)
with pytest.raises(RuntimeError):
_edit(client, target_id)
with app.app_context():
user = db.session.get(User, target_id)
assert isinstance(user, Player)
assert user.role == 'player'