"""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 ( can_manage_player_contract, coach_can_access_player, coach_org_team_ids, coach_player_ids, visible_org_teams, ) @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)) class TestSecondTeam: """ARCH-002 — the routes resolved a coach's team with OrgTeam.query.filter_by(coach_id=...).first() which answers with at most one team, and only ever the legacy column. A coach of two teams therefore reached half of their squad; a coach attached by the relationship only reached none of it. Both defects were live, and silent: the pages rendered, just empty.""" def test_a_coach_of_two_teams_reaches_both_squads( self, app, make_user, team_factory ): coach_id = make_user('coach') first_player = make_user('player') second_player = make_user('player') team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player]) team_factory('JV', coach_id=coach_id, player_ids=[second_player]) with app.app_context(): from app.models import User coach = db.session.get(User, coach_id) assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player]) def test_a_contract_may_be_filed_for_a_player_of_the_second_team( self, app, make_user, team_factory ): coach_id = make_user('coach') team_factory('Varsity', legacy_coach_id=coach_id) second_player = make_user('player') team_factory('JV', coach_id=coach_id, player_ids=[second_player]) with app.app_context(): from app.models import User coach = db.session.get(User, coach_id) assert can_manage_player_contract(coach, second_player) def test_a_contract_is_still_refused_for_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 coach = db.session.get(User, coach_id) assert not can_manage_player_contract(coach, stranger_id) def test_the_contract_page_lists_the_players_of_every_team( self, app, client, as_role, make_user, team_factory ): coach_id = as_role('coach') first_player = make_user('player', username='alpha') second_player = make_user('player', username='bravo') team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player]) team_factory('JV', coach_id=coach_id, player_ids=[second_player]) body = client.get('/users/contracts/upload').get_data(as_text=True) assert 'alpha' in body assert 'bravo' in body def test_the_notes_dashboard_lists_the_players_of_every_team( self, app, client, as_role, make_user, team_factory ): coach_id = as_role('coach') first_player = make_user('player', username='charlie') second_player = make_user('player', username='delta') team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player]) team_factory('JV', coach_id=coach_id, player_ids=[second_player]) body = client.get('/users/notes-dashboard').get_data(as_text=True) assert 'charlie' in body assert 'delta' in body class TestTeamVisibility: def test_a_coach_attached_by_the_relationship_sees_their_team( self, app, make_user, team_factory ): coach_id = make_user('coach') team_factory('Varsity', coach_id=coach_id) with app.app_context(): from app.models import User teams = visible_org_teams(db.session.get(User, coach_id)) assert [t.name for t in teams] == ['Varsity'] def test_a_president_sees_every_team(self, app, make_user, team_factory): admin_id = make_user('admin') other_coach = make_user('coach') team_factory('Varsity', coach_id=other_coach) team_factory('JV', legacy_coach_id=other_coach) with app.app_context(): from app.models import User teams = visible_org_teams(db.session.get(User, admin_id)) assert [t.name for t in teams] == ['JV', 'Varsity'] def test_a_scout_sees_none(self, app, make_user, team_factory): scout_id = make_user('scout') coach_id = make_user('coach') team_factory('Varsity', coach_id=coach_id) with app.app_context(): from app.models import User assert visible_org_teams(db.session.get(User, scout_id)) == [] def test_the_team_listing_shows_the_relationship_team( self, app, client, as_role, team_factory ): coach_id = as_role('coach') team_factory('Northern Lights', coach_id=coach_id) body = client.get('/teams').get_data(as_text=True) assert 'Northern Lights' in body class TestTryoutVisibility: """Coach.get_visible_tryouts() read the many-to-many relationship only, so a coach attached by the legacy column had an empty calendar.""" @staticmethod def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None): with app.app_context(): tryout = Tryout(title=title, game='Valorant', date=date(2030, 5, 1), created_by=creator_id, target_org_team_id=target_team_id, coach_id=legacy_coach_id) db.session.add(tryout) db.session.commit() return tryout.id def test_a_tryout_targeting_a_legacy_team_is_visible( self, app, make_user, team_factory ): coach_id = make_user('coach') team_id = team_factory('Varsity', legacy_coach_id=coach_id) self._tryout(app, creator_id=coach_id, title='Spring intake', target_team_id=team_id) with app.app_context(): from app.models import User coach = db.session.get(User, coach_id) assert [t.title for t in coach.get_visible_tryouts()] == ['Spring intake'] def test_the_same_tryout_is_manageable(self, app, make_user, team_factory): coach_id = make_user('coach') team_id = team_factory('Varsity', legacy_coach_id=coach_id) tryout_id = self._tryout(app, creator_id=coach_id, title='Spring intake', target_team_id=team_id) with app.app_context(): from app.models import User coach = db.session.get(User, coach_id) assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id)) def test_another_coachs_tryout_stays_out_of_reach( self, app, make_user, team_factory ): coach_id = make_user('coach') other_id = make_user('coach') team_factory('Varsity', coach_id=coach_id) other_team = team_factory('JV', coach_id=other_id) tryout_id = self._tryout(app, creator_id=other_id, title='Their intake', target_team_id=other_team) with app.app_context(): from app.models import User coach = db.session.get(User, coach_id) assert coach.get_visible_tryouts() == [] assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id)) class TestOneOnOnePage: """A player whose team lists its coaches through the relationship was told they had no coach, and the request form stayed closed.""" #: The request form and its availability payload are rendered under #: `{% if coach %}`. Asserting on this marker rather than on wording #: keeps the test about the capability, not about a translated string. REQUEST_FORM = 'id="coach-availability-data"' def test_a_player_finds_the_coach_attached_by_the_relationship( self, app, client, as_role, make_user, team_factory ): coach_id = make_user('coach') player_id = as_role('player') team_factory('Varsity', coach_id=coach_id, player_ids=[player_id]) body = client.get('/users/one-on-one').get_data(as_text=True) assert self.REQUEST_FORM in body def test_a_teamless_player_gets_no_request_form(self, app, client, as_role): as_role('player') body = client.get('/users/one-on-one').get_data(as_text=True) assert self.REQUEST_FORM not in body