Files
team-tryouts/tests/test_permissions.py
T
GGThedandClaude Opus 5 afab7070fb fix(authz): une seule regle pour l'acces coach vers joueur
SEC-AUTHZ-004 et SEC-AUTHZ-005. La meme question -- ce coach peut-il agir
sur ce joueur ? -- recevait cinq reponses differentes selon la route :

  teams.py:add_player_note     verifiait l'appartenance via TeamPlayer
  users.py, 4 routes de notes  ne verifiaient rien au-dela d'isinstance
  contract.py:can_view         interrogeait la colonne heritee coach_id, et
                               traitait un team_id nul comme un joker

Consequences levees
  - tout coach pouvait ecrire une note nominative sur tout joueur du club.
    Ces notes sont visibles par le joueur concerne.
  - tout coach figurant dans OrgTeam.coach_id pouvait lire n'importe quel
    contrat sans equipe rattachee. Or upload_contract laisse team_id nul des
    que le joueur n'appartient a aucune equipe : la condition
    `not self.team_id or ...` ouvrait donc largement.
  - symetriquement, un coach rattache uniquement par la relation
    many-to-many ne voyait aucun contrat.

app/permissions.py
  Premier pas concret vers ARCH-002, sans refonte : un module unique, pas
  une couche de services. coach_org_team_ids() lit la relation m2m ET la
  colonne heritee, donc le deuxieme coach d'une equipe cesse d'etre
  invisible. coach_can_access_player() accorde l'acces si le joueur est sur
  une equipe du coach, ou inscrit a un tryout qu'il gere, ou participant a
  un match de ce tryout.

14 tests, dont deux verifient que les chemins legitimes fonctionnent
toujours : un coach note bien son propre joueur, et voit bien son contrat.

Note : la regle metier retenue -- equipe OU tryout -- est une lecture du
comportement existant, pas une decision produit. Si le club attend autre
chose, c'est desormais un seul endroit a changer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:17:49 -04:00

239 lines
9.0 KiB
Python

"""Coach ↔ player access rules.
SEC-AUTHZ-004 and SEC-AUTHZ-005. The same question — "may this coach act on
this player?" — was answered five different ways across the codebase:
teams.py:add_player_note checked TeamPlayer membership
users.py, four note routes checked nothing beyond isinstance(Coach)
contract.py:can_view checked the legacy coach_id column, and
treated a null team_id as a wildcard
app/permissions.py now holds one rule, and it reads both the many-to-many
relationship and the legacy column — so the second coach of a team is no
longer invisible to it (ARCH-002).
"""
from datetime import date
import pytest
from app.extensions import db
from app.models import (
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
)
from app.permissions import coach_can_access_player, coach_org_team_ids
@pytest.fixture
def team_factory(app):
def _make(name, *, coach_id=None, legacy_coach_id=None, player_ids=()):
with app.app_context():
from app.models import User
team = OrgTeam(name=name, created_by=coach_id or legacy_coach_id,
coach_id=legacy_coach_id)
db.session.add(team)
db.session.flush()
if coach_id:
team.coaches.append(db.session.get(User, coach_id))
for player_id in player_ids:
db.session.add(TeamPlayer(player_id=player_id, org_team_id=team.id))
db.session.commit()
return team.id
return _make
class TestTeamResolution:
def test_a_coach_attached_by_the_relationship_is_found(
self, app, make_user, team_factory
):
coach_id = make_user('coach')
team_id = team_factory('Varsity', coach_id=coach_id)
with app.app_context():
from app.models import User
coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id]
def test_a_coach_attached_by_the_legacy_column_is_found(
self, app, make_user, team_factory
):
coach_id = make_user('coach')
team_id = team_factory('JV', legacy_coach_id=coach_id)
with app.app_context():
from app.models import User
coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id]
def test_an_unattached_coach_has_no_team(self, app, make_user):
coach_id = make_user('coach')
with app.app_context():
from app.models import User
assert coach_org_team_ids(db.session.get(User, coach_id)) == []
class TestPlayerAccess:
def test_a_coach_reaches_a_player_on_their_team(self, app, make_user, team_factory):
coach_id = make_user('coach')
player_id = make_user('player')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
with app.app_context():
from app.models import User
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
def test_a_second_coach_of_the_team_also_reaches_the_player(
self, app, make_user, team_factory
):
"""The case that used to fail everywhere users.py looked at coach_id."""
first_coach = make_user('coach')
second_coach = make_user('coach')
player_id = make_user('player')
team_id = team_factory('Varsity', legacy_coach_id=first_coach,
player_ids=[player_id])
with app.app_context():
from app.models import User
team = db.session.get(OrgTeam, team_id)
team.coaches.append(db.session.get(User, second_coach))
db.session.commit()
assert coach_can_access_player(db.session.get(User, second_coach), player_id)
def test_a_coach_does_not_reach_an_unrelated_player(
self, app, make_user, team_factory
):
coach_id = make_user('coach')
stranger_id = make_user('player')
team_factory('Varsity', coach_id=coach_id)
with app.app_context():
from app.models import User
assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id)
def test_a_coach_reaches_a_player_registered_in_their_tryout(
self, app, make_user
):
coach_id = make_user('coach')
player_id = make_user('player')
with app.app_context():
from app.models import User
tryout = Tryout(title='Open tryout', game='Valorant',
date=date(2030, 3, 1), created_by=coach_id)
db.session.add(tryout)
db.session.flush()
tryout.coaches.append(db.session.get(User, coach_id))
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
db.session.commit()
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
def test_a_missing_player_id_is_refused(self, app, make_user):
coach_id = make_user('coach')
with app.app_context():
from app.models import User
assert not coach_can_access_player(db.session.get(User, coach_id), None)
class TestPersonalNoteRoutes:
"""SEC-AUTHZ-004 — every coach could write a nominative note about
every player of the club. The notes are visible to the player."""
def test_a_coach_cannot_note_an_unrelated_player(
self, app, client, as_role, make_user, team_factory
):
stranger_id = make_user('player')
coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id)
client.post('/users/personal-notes/manage', data={
'player_id': stranger_id, 'content': 'Unrelated observation',
}, follow_redirects=True)
with app.app_context():
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
def test_a_coach_can_still_note_their_own_player(
self, app, client, as_role, make_user, team_factory
):
"""Guard against over-correcting."""
player_id = make_user('player')
coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
client.post('/users/personal-notes/manage', data={
'player_id': player_id, 'content': 'Good positioning today',
}, follow_redirects=True)
with app.app_context():
note = PersonalNote.query.filter_by(player_id=player_id).one()
assert note.content == 'Good positioning today'
class TestContractVisibility:
"""SEC-AUTHZ-005 — `not self.team_id` was a wildcard, and upload_contract
leaves team_id null whenever the player has no team."""
@staticmethod
def _contract(app, player_id, uploader_id, team_id=None):
with app.app_context():
contract = Contract(
player_id=player_id, team_id=team_id, uploaded_by_id=uploader_id,
original_filename='c.pdf', stored_filename='uuid.pdf',
file_path='/tmp/uuid.pdf',
)
db.session.add(contract)
db.session.commit()
return contract.id
def test_a_teamless_contract_is_not_visible_to_every_coach(
self, app, make_user, team_factory
):
coach_id = make_user('coach')
stranger_id = make_user('player')
admin_id = make_user('admin')
team_factory('Varsity', legacy_coach_id=coach_id)
contract_id = self._contract(app, stranger_id, admin_id, team_id=None)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert not contract.can_view(db.session.get(User, coach_id))
def test_a_coach_sees_the_contract_of_their_own_player(
self, app, make_user, team_factory
):
coach_id = make_user('coach')
player_id = make_user('player')
admin_id = make_user('admin')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, coach_id))
def test_the_player_always_sees_their_own_contract(self, app, make_user):
player_id = make_user('player')
admin_id = make_user('admin')
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, player_id))
def test_an_admin_sees_every_contract(self, app, make_user):
player_id = make_user('player')
admin_id = make_user('admin')
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, admin_id))