"""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))