diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index fcca30c..6ea7971 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -120,6 +120,10 @@ def list_matches(): def create_match(team_id): """Create a new regular-season team match.""" team = db.get_or_404(OrgTeam, team_id) + if season_locked(): + flash('The regular season is not active. Begin a season before scheduling matches.', 'warning') + return redirect(url_for('team_matches.list_matches')) + if not can_manage_team_match(team): flash(_('You do not have permission to schedule matches for this team.'), 'danger') return redirect(url_for('team_matches.list_matches')) @@ -272,9 +276,14 @@ def delete_match(match_id): """Delete a team match.""" team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team + if season_locked(): + flash('The regular season is not active. Begin a season before deleting matches.', 'warning') + return redirect(url_for('team_matches.list_matches')) + if not can_manage_team_match(team): flash(_('You do not have permission to delete this match.'), 'danger') return redirect(url_for('team_matches.list_matches')) + db.session.delete(team_match) db.session.commit() flash(_('Match deleted successfully.'), 'success') diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 38f7f0c..33fa9b2 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -139,12 +139,12 @@ def list_tryouts(): @login_required def create_tryout(): """Create a new tryout event. Requires Admin or Manager.""" - if not can_manage(): - flash(_('You do not have permission to create tryouts.'), 'danger') + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') + if not can_manage(): + flash(_('You do not have permission to create tryouts.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) org_teams = OrgTeam.query.order_by(OrgTeam.name).all() @@ -203,14 +203,14 @@ def edit_tryout(tryout_id): """Edit an existing tryout event. Permission based on can_manage_this_tryout.""" tryout = db.get_or_404(Tryout, tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to edit this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) - if tryout.is_ended: flash(_('This tryout has ended and can no longer be modified.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) @@ -499,12 +499,13 @@ def register_for_tryout(tryout_id): def update_status(tryout_id): """Update the status of a tryout.""" tryout = db.get_or_404(Tryout, tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = TryoutStatusSchema().load(form_payload(list_fields=())) except ValidationError as err: @@ -522,14 +523,14 @@ def update_status(tryout_id): def update_registration_status(tryout_id, player_id): """Update a registration's attendance status.""" tryout = db.get_or_404(Tryout, tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id ).first_or_404() @@ -550,12 +551,13 @@ def update_registration_status(tryout_id, player_id): def register_player(tryout_id): """Manually register a player for a tryout (by managers/coaches).""" tryout = locked_tryout_or_404(tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = PlayerSelectionSchema().load(form_payload()) except ValidationError as err: @@ -600,14 +602,14 @@ def register_player(tryout_id): def remove_player(tryout_id, player_id): """Remove a registered player from a tryout (cascades to teams/matches).""" tryout = db.get_or_404(Tryout, tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - player = db.get_or_404(User, player_id) registration = TryoutRegistration.query.filter_by( @@ -640,12 +642,12 @@ def remove_player(tryout_id, player_id): def create_team(tryout_id): """Create a tryout-specific team.""" tryout = db.get_or_404(Tryout, tryout_id) - if not current_user.can_manage_this_tryout(tryout): - flash(_('Permission denied.'), 'danger') + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') + if not current_user.can_manage_this_tryout(tryout): + flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: @@ -667,12 +669,12 @@ def add_to_team(tryout_id, team_id): """Add a player to a tryout team.""" team = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) - if not current_user.can_manage_this_tryout(tryout): - flash(_('Permission denied.'), 'danger') + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') + if not current_user.can_manage_this_tryout(tryout): + flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # The two ids arrive independently in the URL. Without this check, being @@ -713,14 +715,14 @@ def add_to_team(tryout_id, team_id): def delete_tryout(tryout_id): """Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" tryout = db.get_or_404(Tryout, tryout_id) + if tryouts_locked(): + flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - if tryouts_locked(): - flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()] team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()] diff --git a/app/templates/pages/admin.html b/app/templates/pages/admin.html index b33b62c..73bb47a 100644 --- a/app/templates/pages/admin.html +++ b/app/templates/pages/admin.html @@ -174,7 +174,6 @@

Backup History & Restore

- {% if backups %}
@@ -213,13 +212,14 @@ + {% else %} + + + {% endfor %}
No backups yet. Create your first backup above.
- {% else %} -

No backups yet. Create your first backup above.

- {% endif %}
@@ -230,7 +230,6 @@

Audit Log (Recent)

- {% if audit_logs %}
@@ -251,13 +250,14 @@ + {% else %} + + + {% endfor %}
{{ entry.details or '—' }} {{ entry.ip_address or '—' }}
No audit log entries yet.
- {% else %} -

No audit log entries yet.

- {% endif %}
diff --git a/tests/conftest.py b/tests/conftest.py index 969727e..ccefa72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -194,6 +194,11 @@ def as_role(app, client, make_user, login): from app.models import User username = _db.session.get(User, user_id).username + # The login route short-circuits when the client is already + # authenticated, so a previous as_role() call would otherwise leave + # the old identity in the session and the switch would silently not + # happen (ADMIN-010: coach/manager gate tests ran as admin). + client.post('/auth/logout', follow_redirects=False) response = login(username) assert response.status_code in (301, 302), ( f'login for {username} did not redirect: {response.status_code}' diff --git a/tests/test_admin_app_settings.py b/tests/test_admin_app_settings.py new file mode 100644 index 0000000..11a72d8 --- /dev/null +++ b/tests/test_admin_app_settings.py @@ -0,0 +1,62 @@ +"""AppSettings key-value store — model behaviour. + +ADMIN-001. The admin panel relies on AppSettings for global toggles +(tryouts_open, season_active, season_name, season_start, season_end). +These tests verify the key-value store itself, independent of any route. +""" + +import pytest + +from app.extensions import db +from app.models import AppSettings + + +class TestAppSettingsGetSet: + def test_get_returns_default_for_missing_key(self, app): + with app.app_context(): + assert AppSettings.get('nonexistent', 'fallback') == 'fallback' + + def test_set_and_get_roundtrip(self, app): + with app.app_context(): + AppSettings.set('test_key', 'hello') + assert AppSettings.get('test_key') == 'hello' + + def test_set_overwrites_existing_key(self, app): + with app.app_context(): + AppSettings.set('overwrite_key', 'first') + AppSettings.set('overwrite_key', 'second') + assert AppSettings.get('overwrite_key') == 'second' + + def test_set_none_value_is_stored_as_none(self, app): + with app.app_context(): + AppSettings.set('nullable_key', None) + assert AppSettings.get('nullable_key') is None + + +class TestAppSettingsBool: + def test_get_bool_parses_true_values(self, app): + with app.app_context(): + for val in ('true', 'True', 'TRUE', '1', 'yes', 'on'): + AppSettings.set('bool_test', val) + assert AppSettings.get_bool('bool_test'), f'{val!r} should be True' + + def test_get_bool_parses_false_values(self, app): + with app.app_context(): + for val in ('false', 'False', 'FALSE', '0', 'no', 'off', 'anything_else'): + AppSettings.set('bool_test', val) + assert not AppSettings.get_bool('bool_test'), f'{val!r} should be False' + + def test_get_bool_defaults_to_false_for_missing_key(self, app): + with app.app_context(): + assert not AppSettings.get_bool('does_not_exist') + + def test_get_bool_respects_custom_default(self, app): + with app.app_context(): + assert AppSettings.get_bool('does_not_exist', default=True) + + def test_set_bool_stores_as_string(self, app): + with app.app_context(): + AppSettings.set_bool('bool_key', True) + assert AppSettings.get('bool_key') == 'true' + AppSettings.set_bool('bool_key', False) + assert AppSettings.get('bool_key') == 'false' \ No newline at end of file diff --git a/tests/test_admin_audit_log.py b/tests/test_admin_audit_log.py new file mode 100644 index 0000000..5dca65c --- /dev/null +++ b/tests/test_admin_audit_log.py @@ -0,0 +1,61 @@ +"""AuditLog model — append-only admin action tracking. + +ADMIN-002. Every sensitive admin action (backup, restore, toggle, season, +wipe) writes an AuditLog entry. These tests verify the model itself. +""" + +import pytest + +from app.extensions import db +from app.models import AuditLog, User + + +class TestAuditLogRecord: + def test_record_stores_all_fields(self, app, make_user): + admin_id = make_user('admin') + with app.app_context(): + AuditLog.record( + user_id=admin_id, + action='test_action', + details='some details here', + ip_address='192.168.1.1', + ) + entry = AuditLog.query.order_by(AuditLog.id.desc()).first() + assert entry is not None + assert entry.user_id == admin_id + assert entry.action == 'test_action' + assert entry.details == 'some details here' + assert entry.ip_address == '192.168.1.1' + assert entry.created_at is not None + + def test_record_without_details_or_ip(self, app, make_user): + admin_id = make_user('admin') + with app.app_context(): + AuditLog.record(user_id=admin_id, action='minimal') + entry = AuditLog.query.order_by(AuditLog.id.desc()).first() + assert entry.action == 'minimal' + assert entry.details is None + assert entry.ip_address is None + + def test_entries_are_ordered_by_date_descending(self, app, make_user): + admin_id = make_user('admin') + with app.app_context(): + AuditLog.record(user_id=admin_id, action='first') + AuditLog.record(user_id=admin_id, action='second') + AuditLog.record(user_id=admin_id, action='third') + + entries = AuditLog.query.order_by(AuditLog.created_at.desc()).all() + actions = [e.action for e in entries] + assert actions == ['third', 'second', 'first'] + + def test_audit_log_survives_user_deletion(self, app, make_user): + admin_id = make_user('admin') + with app.app_context(): + AuditLog.record(user_id=admin_id, action='before_delete') + user = db.session.get(User, admin_id) + db.session.delete(user) + db.session.commit() + + entry = AuditLog.query.filter_by(action='before_delete').first() + assert entry is not None + assert entry.user_id is None # FK set to NULL on delete \ No newline at end of file diff --git a/tests/test_admin_backup.py b/tests/test_admin_backup.py new file mode 100644 index 0000000..5eefedd --- /dev/null +++ b/tests/test_admin_backup.py @@ -0,0 +1,285 @@ +"""Admin backup — create, download, delete, restore, and audit trail. + +ADMIN-008. The backup buttons on the admin panel run pg_dump/pg_restore +in production, but tests mock the subprocess boundary. These tests verify +the route logic, the BackupRecord model, and audit logging. +""" + +import os + +import pytest + +from app.extensions import db +from app.models import AuditLog, BackupRecord + + +def _redirected(response): + return response.status_code in (301, 302) + + +class TestBackupCreate: + def test_create_backup_succeeds(self, app, client, as_role, monkeypatch): + import app.routes.admin as admin_module + from app.supporting_scripts.backup import BackupError + + def fake_create_backup_record(backup_type='manual', notes=None): + from app.models import BackupRecord + + record = BackupRecord( + filename='db_backup_test.dump', + file_path='/tmp/db_backup_test.dump', + size_bytes=1234, + backup_type=backup_type, + notes=notes, + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + return record + + monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record) + + as_role('admin') + response = client.post( + '/admin/backup/create', data={'notes': 'test backup'}, follow_redirects=False, + ) + assert _redirected(response) + + with app.app_context(): + record = BackupRecord.query.filter_by(filename='db_backup_test.dump').first() + assert record is not None + assert record.notes == 'test backup' + + def test_create_backup_failure_is_handled(self, app, client, as_role, monkeypatch): + import app.routes.admin as admin_module + from app.supporting_scripts.backup import BackupError + + def fake_create_backup_record(backup_type='manual', notes=None): + raise BackupError('pg_dump not found') + + monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record) + + as_role('admin') + response = client.post( + '/admin/backup/create', data={}, follow_redirects=True, + ) + html = response.data.decode() + assert 'Backup failed' in html + + def test_create_backup_creates_audit_log(self, app, client, as_role, monkeypatch): + import app.routes.admin as admin_module + + def fake_create_backup_record(backup_type='manual', notes=None): + from app.models import BackupRecord + + record = BackupRecord( + filename='db_backup_test.dump', + file_path='/tmp/db_backup_test.dump', + size_bytes=1234, + backup_type=backup_type, + notes=notes, + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + return record + + monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record) + + as_role('admin') + client.post('/admin/backup/create', data={}, follow_redirects=False) + + with app.app_context(): + entry = AuditLog.query.filter_by(action='backup_created').first() + assert entry is not None + + +class TestBackupDownload: + def test_download_existing_backup(self, app, client, as_role, tmp_path): + from app.models import BackupRecord + + backup_file = tmp_path / 'db_backup_test.dump' + backup_file.write_text('fake dump content') + + as_role('admin') + with app.app_context(): + record = BackupRecord( + filename='db_backup_test.dump', + file_path=str(backup_file), + size_bytes=18, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + response = client.get(f'/admin/backup/{record_id}/download') + assert response.status_code == 200 + assert response.data == b'fake dump content' + + def test_download_missing_backup_file(self, app, client, as_role): + from app.models import BackupRecord + + as_role('admin') + with app.app_context(): + record = BackupRecord( + filename='missing.dump', + file_path='/nonexistent/missing.dump', + size_bytes=0, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + response = client.get( + f'/admin/backup/{record_id}/download', follow_redirects=True, + ) + html = response.data.decode() + assert 'missing from disk' in html.lower() + + +class TestBackupDelete: + def test_delete_backup_removes_record_and_file(self, app, client, as_role, tmp_path): + from app.models import BackupRecord + + backup_file = tmp_path / 'db_backup_delete.dump' + backup_file.write_text('fake dump content') + + as_role('admin') + with app.app_context(): + record = BackupRecord( + filename='db_backup_delete.dump', + file_path=str(backup_file), + size_bytes=18, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + response = client.post( + f'/admin/backup/{record_id}/delete', follow_redirects=False, + ) + assert _redirected(response) + + with app.app_context(): + assert BackupRecord.query.get(record_id) is None + assert not backup_file.exists() + + def test_delete_backup_creates_audit_log(self, app, client, as_role, tmp_path): + from app.models import BackupRecord + + backup_file = tmp_path / 'db_backup_delete2.dump' + backup_file.write_text('fake') + + as_role('admin') + with app.app_context(): + record = BackupRecord( + filename='db_backup_delete2.dump', + file_path=str(backup_file), + size_bytes=4, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + client.post(f'/admin/backup/{record_id}/delete', follow_redirects=False) + + with app.app_context(): + entry = AuditLog.query.filter_by(action='backup_deleted').first() + assert entry is not None + + +class TestBackupRestore: + def test_restore_with_missing_file_fails(self, app, client, as_role): + from app.models import BackupRecord + + as_role('admin') + with app.app_context(): + record = BackupRecord( + filename='missing_restore.dump', + file_path='/nonexistent/missing_restore.dump', + size_bytes=0, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + response = client.post( + f'/admin/backup/{record_id}/restore', follow_redirects=True, + ) + html = response.data.decode() + assert 'missing from disk' in html.lower() + + def test_restore_creates_safety_backup_first( + self, app, client, as_role, monkeypatch, tmp_path + ): + import app.routes.admin as admin_module + + # The restore route parses DATABASE_URL outside its try block. + monkeypatch.setenv('DATABASE_URL', 'postgresql://appuser:pw@db.example.test:5432/tryouts') + + backup_file = tmp_path / 'db_backup_restore.dump' + backup_file.write_text('fake') + + safety_file = tmp_path / 'db_backup_safety.dump' + safety_file.write_text('fake safety') + + as_role('admin') + with app.app_context(): + from app.models import BackupRecord + + record = BackupRecord( + filename='db_backup_restore.dump', + file_path=str(backup_file), + size_bytes=4, + backup_type='manual', + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + record_id = record.id + + def fake_create_backup_record(backup_type='pre_restore', notes=None): + from app.models import BackupRecord + + record = BackupRecord( + filename='db_backup_safety.dump', + file_path=str(safety_file), + size_bytes=12, + backup_type=backup_type, + notes=notes, + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + return record + + monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record) + + import subprocess + + def fake_subprocess_run(cmd, env=None, capture_output=True, text=True, timeout=None): + class Result: + returncode = 0 + stderr = '' + stdout = '' + + return Result() + + monkeypatch.setattr(subprocess, 'run', fake_subprocess_run) + + client.post(f'/admin/backup/{record_id}/restore', follow_redirects=False) + + with app.app_context(): + safety = BackupRecord.query.filter_by(filename='db_backup_safety.dump').first() + assert safety is not None + assert safety.backup_type == 'pre_restore' \ No newline at end of file diff --git a/tests/test_admin_csrf.py b/tests/test_admin_csrf.py new file mode 100644 index 0000000..934c549 --- /dev/null +++ b/tests/test_admin_csrf.py @@ -0,0 +1,56 @@ +"""Admin panel — CSRF protection on mutation endpoints. + +ADMIN-005. Every admin POST route must reject requests that lack a valid +CSRF token. These tests use the app_with_csrf fixture where CSRF is +enabled, unlike the default app fixture which disables it. +""" + +import pytest + + +ADMIN_POST_ROUTES = [ + '/admin/backup/create', + '/admin/toggle-tryouts', + '/admin/season/start', + '/admin/season/end', + '/admin/teams/wipe', +] + + +class TestAdminCsrfProtection: + @pytest.mark.parametrize('route', ADMIN_POST_ROUTES) + def test_post_without_csrf_is_rejected(self, app_with_csrf, route): + """Every admin POST route must reject a missing CSRF token.""" + # as_role uses the default app fixture, so we log in manually + # with the csrf-enabled app. + from app.extensions import db as _db + from app.models import User + + client = app_with_csrf.test_client() + + # Create and log in an admin on the CSRF-enabled app + with app_with_csrf.app_context(): + from app.extensions import hash_password + from app.models import Admin + + admin = Admin( + username='csrfadmin', + password_hash=hash_password('Password123'), + role='admin', + full_name='CSRF Admin', + email='csrfadmin@example.test', + ) + _db.session.add(admin) + _db.session.commit() + + client.post( + '/auth/login', + data={'username': 'csrfadmin', 'password': 'Password123'}, + follow_redirects=False, + ) + + response = client.post(route, data={}, follow_redirects=False) + # Flask-WTF returns 400 on missing CSRF token + assert response.status_code in (400, 302), ( + f'{route} returned {response.status_code} without CSRF token' + ) \ No newline at end of file diff --git a/tests/test_admin_panel.py b/tests/test_admin_panel.py new file mode 100644 index 0000000..8b85e7f --- /dev/null +++ b/tests/test_admin_panel.py @@ -0,0 +1,150 @@ +"""Admin panel — access control, dashboard rendering, and template integrity. + +ADMIN-004. The admin panel at /admin must only be reachable by admins, +must render all expected sections, and must serve static assets correctly. +""" + +import pytest + +from app.extensions import db +from app.models import User + + +NON_ADMIN_ROLES = ['player', 'coach', 'manager', 'scout'] + +ADMIN_MUTATION_ROUTES = [ + '/admin/backup/create', + '/admin/toggle-tryouts', + '/admin/season/start', + '/admin/season/end', + '/admin/teams/wipe', +] + + +def _redirected(response): + return response.status_code in (301, 302) + + +# --------------------------------------------------------------------------- +# Access control +# --------------------------------------------------------------------------- + + +class TestAdminAccessControl: + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_is_redirected_from_dashboard(self, client, as_role, role): + as_role(role) + response = client.get('/admin', follow_redirects=False) + assert _redirected(response), f'{role} reached /admin' + + def test_admin_reaches_dashboard(self, client, as_role): + as_role('admin') + response = client.get('/admin') + assert response.status_code == 200 + + @pytest.mark.parametrize('route', ADMIN_MUTATION_ROUTES) + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_post_to_admin_routes(self, client, as_role, role, route): + as_role(role) + response = client.post(route, data={}, follow_redirects=False) + assert _redirected(response), f'{role} reached {route}' + + +# --------------------------------------------------------------------------- +# Dashboard rendering +# --------------------------------------------------------------------------- + + +class TestAdminDashboardRendering: + def test_dashboard_shows_stats(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + assert 'Total Users' in html + assert 'Players' in html + assert 'Org Teams' in html + assert 'Active Tryouts' in html + + def test_dashboard_shows_tryout_status(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + # Either OPEN or CLOSED badge must be present + assert 'OPEN' in html or 'CLOSED' in html + + def test_dashboard_shows_season_info(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + assert 'Season Management' in html + assert 'Name:' in html + + def test_dashboard_shows_audit_log_section(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + assert 'Audit Log' in html + + def test_dashboard_shows_backup_section(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + assert 'Manual Backup' in html + assert 'Backup History' in html + + +# --------------------------------------------------------------------------- +# Template integrity +# --------------------------------------------------------------------------- + + +class TestAdminTemplateIntegrity: + def test_page_returns_200(self, client, as_role): + as_role('admin') + response = client.get('/admin') + assert response.status_code == 200 + + def test_page_has_html_doctype(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert '' in html or '' in html.lower() + + def test_all_buttons_are_present(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + # Key buttons that must exist + assert 'Create Backup Now' in html + assert 'Wipe Team Rosters' in html + # Toggle button text depends on state + assert 'Tryouts' in html or 'tryouts' in html.lower() + + def test_all_forms_have_csrf_tokens(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + + # Count
tags and csrf_token occurrences + form_count = html.count(' 0, 'No forms found on admin page' + assert csrf_count >= form_count, ( + f'Found {form_count} forms but only {csrf_count} csrf_token(s)' + ) + + def test_static_css_loads(self, client): + response = client.get('/static/css/style.css') + assert response.status_code == 200 + assert 'text/css' in response.content_type + + def test_static_js_loads(self, client): + response = client.get('/static/js/main.js') + assert response.status_code == 200 + assert 'javascript' in response.content_type or 'text/' in response.content_type \ No newline at end of file diff --git a/tests/test_admin_route_map.py b/tests/test_admin_route_map.py new file mode 100644 index 0000000..fd36390 --- /dev/null +++ b/tests/test_admin_route_map.py @@ -0,0 +1,49 @@ +"""Admin route registration — every endpoint must exist and require login. + +ADMIN-003. The admin blueprint registers 10 routes. If one is accidentally +removed or renamed, the admin panel breaks silently. These tests walk the +URL map to catch that. +""" + +import pytest + + +ADMIN_ROUTES = [ + '/admin', + '/admin/backup/create', + '/admin/toggle-tryouts', + '/admin/season/start', + '/admin/season/end', + '/admin/teams/wipe', +] + + +def _redirected(response): + return response.status_code in (301, 302) + + +class TestAdminRouteRegistration: + @pytest.mark.parametrize('route', ADMIN_ROUTES) + def test_route_is_registered(self, app, route): + """Every admin endpoint must appear in the URL map.""" + adapter = app.url_map.bind('localhost') + try: + adapter.match(route, method='GET') + except Exception: + # Some routes are POST-only; try POST + try: + adapter.match(route, method='POST') + except Exception as exc: + pytest.fail(f'Route {route} is not registered: {exc}') + + @pytest.mark.parametrize('route', ADMIN_ROUTES) + def test_route_requires_login(self, client, route): + """Unauthenticated access must redirect to login.""" + response = client.get(route, follow_redirects=False) + # POST-only routes return 405 on GET, which is fine — the point is + # they don't return 200 to an anonymous caller. + if response.status_code == 405: + return + assert _redirected(response), ( + f'{route} answered {response.status_code} to an anonymous caller' + ) \ No newline at end of file diff --git a/tests/test_admin_season.py b/tests/test_admin_season.py new file mode 100644 index 0000000..e40690a --- /dev/null +++ b/tests/test_admin_season.py @@ -0,0 +1,170 @@ +"""Admin season lifecycle — start/end and gate on team matches. + +ADMIN-007. The season_active setting gates regular-season match scheduling +for non-admins. These tests verify start/end season and the gate. +""" + +import pytest + +from app.extensions import db +from app.models import AppSettings, OrgTeam, TeamMatch, User + + +NON_ADMIN_ROLES = ['manager', 'coach'] + + +def _redirected(response): + return response.status_code in (301, 302) + + +# --------------------------------------------------------------------------- +# Season lifecycle +# --------------------------------------------------------------------------- + + +class TestSeasonLifecycle: + def test_start_season_sets_fields(self, client, as_role): + as_role('admin') + client.post( + '/admin/season/start', + data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'}, + follow_redirects=False, + ) + with client.application.app_context(): + assert AppSettings.get('season_name') == 'Fall 2026' + assert AppSettings.get('season_start') == '2026-09-01' + assert AppSettings.get_bool('season_active', default=False) + + def test_cannot_start_season_when_already_active(self, client, as_role): + as_role('admin') + client.post( + '/admin/season/start', + data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'}, + ) + # Second start should be rejected + response = client.post( + '/admin/season/start', + data={'season_name': 'Spring 2027', 'season_start': '2027-01-01'}, + follow_redirects=True, + ) + html = response.data.decode() + assert 'already active' in html.lower() + + def test_start_season_creates_audit_log(self, client, as_role): + from app.models import AuditLog + + as_role('admin') + client.post( + '/admin/season/start', + data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'}, + ) + with client.application.app_context(): + entry = AuditLog.query.filter_by(action='season_started').first() + assert entry is not None + + def test_end_season_sets_end_date_and_deactivates(self, client, as_role): + as_role('admin') + client.post( + '/admin/season/start', + data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'}, + ) + client.post( + '/admin/season/end', + data={'season_end': '2026-12-15'}, + follow_redirects=False, + ) + with client.application.app_context(): + assert AppSettings.get('season_end') == '2026-12-15' + assert not AppSettings.get_bool('season_active', default=False) + + def test_cannot_end_season_when_inactive(self, client, as_role): + as_role('admin') + # No season started + response = client.post( + '/admin/season/end', + data={'season_end': '2026-12-15'}, + follow_redirects=True, + ) + html = response.data.decode() + assert 'no season is currently active' in html.lower() + + def test_end_season_creates_audit_log(self, client, as_role): + from app.models import AuditLog + + as_role('admin') + client.post( + '/admin/season/start', + data={'season_name': 'Fall 2026', 'season_start': '2026-09-01'}, + ) + client.post('/admin/season/end', data={'season_end': '2026-12-15'}) + with client.application.app_context(): + entry = AuditLog.query.filter_by(action='season_ended').first() + assert entry is not None + + +# --------------------------------------------------------------------------- +# Gate enforcement on team matches +# --------------------------------------------------------------------------- + + +class TestSeasonGateEnforcement: + @pytest.fixture + def org_team(self, app, client, as_role): + as_role('admin') + client.post( + '/teams/create', + data={'name': 'Varsity Test'}, + follow_redirects=False, + ) + with app.app_context(): + return OrgTeam.query.filter_by(name='Varsity Test').first().id + + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_create_match_when_season_inactive( + self, client, as_role, org_team, role + ): + as_role(role) + response = client.get( + f'/team-matches/{org_team}/create', + follow_redirects=True, + ) + html = response.data.decode() + assert 'season is not active' in html.lower() or 'begin a season' in html.lower() + + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_delete_match_when_season_inactive( + self, app, client, as_role, org_team, role, make_user + ): + # Create a match as admin (season active not required for admin) + as_role('admin') + # Start season so match can be created + client.post( + '/admin/season/start', + data={'season_name': 'Fall', 'season_start': '2026-09-01'}, + ) + client.post( + f'/team-matches/{org_team}/create', + data={'title': 'Match Test', 'date': '2026-10-01', 'start_time': '10:00'}, + follow_redirects=False, + ) + with app.app_context(): + match_id = TeamMatch.query.filter_by(title='Match Test').first().id + # End season + client.post('/admin/season/end', data={'season_end': '2026-12-15'}) + + as_role(role) + response = client.post( + f'/team-matches/{match_id}/delete', data={}, follow_redirects=True, + ) + html = response.data.decode() + assert 'season is not active' in html.lower() or 'begin a season' in html.lower() + + def test_admin_can_always_schedule_match(self, client, as_role, org_team): + as_role('admin') + # No season active + response = client.post( + f'/team-matches/{org_team}/create', + data={'title': 'Admin Match', 'date': '2026-10-01', 'start_time': '10:00'}, + follow_redirects=False, + ) + assert _redirected(response) # success, not blocked \ No newline at end of file diff --git a/tests/test_admin_template.py b/tests/test_admin_template.py new file mode 100644 index 0000000..b4297de --- /dev/null +++ b/tests/test_admin_template.py @@ -0,0 +1,87 @@ +"""Admin panel UI — buttons, forms, tables, and image loading. + +ADMIN-010. This verifies the admin panel HTML structure: the stats grid, +the tryout toggle button, the season form fields, the wipe confirmation +input, and the backup/audit tables, plus that the logo image is served. +""" + +import pytest + + +class TestAdminStatsGrid: + def test_stats_grid_has_five_cards(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + # Each stat card contains a stat-icon and stat-info + assert html.count('stat-card') >= 5 + + +class TestAdminButtons: + def test_tryout_toggle_button_present(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Open Tryouts' in html or 'Close Tryouts' in html + + def test_backup_button_present(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Create Backup Now' in html + + def test_wipe_button_present(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Wipe Team Rosters' in html + + def test_season_button_present(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Begin Season' in html or 'End Season' in html + + +class TestAdminForms: + def test_season_form_has_name_and_date_fields(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'season_name' in html + assert 'season_start' in html or 'season_end' in html + + def test_wipe_form_has_confirm_input(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'name="confirm"' in html + assert 'WIPE' in html + + +class TestAdminTables: + def test_backup_table_columns(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Filename' in html + assert 'Size' in html + assert 'Type' in html + + def test_audit_log_table_columns(self, client, as_role): + as_role('admin') + response = client.get('/admin') + html = response.data.decode() + assert 'Action' in html + assert 'Details' in html + + +class TestAdminImages: + def test_logo_image_loads(self, client): + response = client.get('/static/images/UdeS_logo.png') + assert response.status_code == 200 + + def test_esports_logo_image_loads(self, client): + # The original logo may still be referenced + response = client.get('/static/images/UdeS_logo.png') + assert response.status_code == 200 \ No newline at end of file diff --git a/tests/test_admin_tryout_toggle.py b/tests/test_admin_tryout_toggle.py new file mode 100644 index 0000000..aa44e89 --- /dev/null +++ b/tests/test_admin_tryout_toggle.py @@ -0,0 +1,156 @@ +"""Admin tryout toggle — open/close gate and enforcement on tryout routes. + +ADMIN-006. The tryouts_open setting gates every tryout mutation route for +non-admins. Admins always bypass the lock. These tests verify the toggle +itself and the gate on each affected route. +""" + +import pytest + +from app.extensions import db +from app.models import AppSettings, Tryout, User + + +NON_ADMIN_ROLES = ['manager', 'coach'] + + +def _redirected(response): + return response.status_code in (301, 302) + + +# --------------------------------------------------------------------------- +# Toggle logic +# --------------------------------------------------------------------------- + + +class TestTryoutToggle: + def test_toggle_from_open_to_closed(self, client, as_role): + as_role('admin') + # Default is open + client.post('/admin/toggle-tryouts', data={}, follow_redirects=False) + # Now should be closed + with client.application.app_context(): + assert not AppSettings.get_bool('tryouts_open', default=True) + + def test_toggle_from_closed_to_open(self, client, as_role): + as_role('admin') + # Close first + client.post('/admin/toggle-tryouts', data={}, follow_redirects=False) + # Open again + client.post('/admin/toggle-tryouts', data={}, follow_redirects=False) + with client.application.app_context(): + assert AppSettings.get_bool('tryouts_open', default=True) + + def test_toggle_creates_audit_log(self, client, as_role): + from app.models import AuditLog + + as_role('admin') + client.post('/admin/toggle-tryouts', data={}, follow_redirects=False) + + with client.application.app_context(): + entry = AuditLog.query.filter_by(action='tryouts_toggled').first() + assert entry is not None + + def test_default_is_open(self, app): + with app.app_context(): + assert AppSettings.get_bool('tryouts_open', default=True) + + +# --------------------------------------------------------------------------- +# Gate enforcement on tryout routes +# --------------------------------------------------------------------------- + + +class TestTryoutGateEnforcement: + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_create_tryout_when_closed(self, client, as_role, role): + as_role('admin') + client.post('/admin/toggle-tryouts', data={}) # close + as_role(role) + response = client.post( + '/tryouts/create', + data={'title': 'Test', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=True, + ) + html = response.data.decode() + assert 'closed' in html.lower() or 'open tryouts' in html.lower() + + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_edit_tryout_when_closed( + self, app, client, as_role, make_user, role + ): + # Create a tryout as admin first + as_role('admin') + client.post( + '/tryouts/create', + data={'title': 'Edit Test', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=False, + ) + with app.app_context(): + tryout_id = Tryout.query.filter_by(title='Edit Test').first().id + + # Close tryouts + client.post('/admin/toggle-tryouts', data={}) + + # Try to edit as non-admin + as_role(role) + response = client.post( + f'/tryouts/{tryout_id}/edit', + data={'title': 'Hacked', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=True, + ) + html = response.data.decode() + assert 'closed' in html.lower() or 'open tryouts' in html.lower() + + @pytest.mark.parametrize('role', NON_ADMIN_ROLES) + def test_non_admin_cannot_delete_tryout_when_closed( + self, app, client, as_role, make_user, role + ): + as_role('admin') + client.post( + '/tryouts/create', + data={'title': 'Delete Test', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=False, + ) + with app.app_context(): + tryout_id = Tryout.query.filter_by(title='Delete Test').first().id + + client.post('/admin/toggle-tryouts', data={}) # close + + as_role(role) + response = client.post( + f'/tryouts/{tryout_id}/delete', data={}, follow_redirects=True, + ) + html = response.data.decode() + assert 'closed' in html.lower() or 'open tryouts' in html.lower() + + def test_admin_can_always_create_tryout(self, client, as_role): + as_role('admin') + # Close tryouts + client.post('/admin/toggle-tryouts', data={}) + # Admin should still be able to create + response = client.post( + '/tryouts/create', + data={'title': 'Admin Test', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=False, + ) + assert _redirected(response) # success redirect, not blocked + + def test_admin_can_always_edit_tryout(self, app, client, as_role): + as_role('admin') + client.post( + '/tryouts/create', + data={'title': 'Admin Edit', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=False, + ) + with app.app_context(): + tryout_id = Tryout.query.filter_by(title='Admin Edit').first().id + + client.post('/admin/toggle-tryouts', data={}) # close + + response = client.post( + f'/tryouts/{tryout_id}/edit', + data={'title': 'Admin Edited', 'game': 'Valorant', 'date': '2026-12-01'}, + follow_redirects=False, + ) + assert _redirected(response) # success, not blocked \ No newline at end of file diff --git a/tests/test_admin_wipe.py b/tests/test_admin_wipe.py new file mode 100644 index 0000000..19f0ac1 --- /dev/null +++ b/tests/test_admin_wipe.py @@ -0,0 +1,119 @@ +"""Admin team wipe — confirmation flow, data removal, and safety backup. + +ADMIN-009. The "Wipe Team Rosters" button must require typing WIPE, +create a safety backup, and remove TeamPlayer + TeamMatch records while +preserving OrgTeam structures. +""" + +import pytest + +from app.extensions import db +from app.models import ( + AuditLog, BackupRecord, OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer, +) + + +def _redirected(response): + return response.status_code in (301, 302) + + +class TestWipeConfirmation: + def test_wipe_without_confirmation_fails(self, app, client, as_role): + as_role('admin') + response = client.post( + '/admin/teams/wipe', data={}, follow_redirects=True, + ) + html = response.data.decode() + assert 'WIPE' in html + + def test_wipe_with_wrong_confirmation_fails(self, app, client, as_role): + as_role('admin') + response = client.post( + '/admin/teams/wipe', data={'confirm': 'yes'}, follow_redirects=True, + ) + html = response.data.decode() + assert 'WIPE' in html + + +class TestWipeDataRemoval: + @pytest.fixture + def seeded_teams(self, app, client, as_role, make_user, monkeypatch): + import app.routes.admin as admin_module + + # Mock the backup so the wipe proceeds without a real pg_dump. + def fake_create_backup_record(backup_type='pre_wipe', notes=None): + from app.models import BackupRecord + + record = BackupRecord( + filename='db_backup_pre_wipe.dump', + file_path='/tmp/db_backup_pre_wipe.dump', + size_bytes=100, + backup_type=backup_type, + notes=notes, + created_by_id=1, + ) + db.session.add(record) + db.session.commit() + return record + + monkeypatch.setattr(admin_module, '_create_backup_record', fake_create_backup_record) + + as_role('admin') + # Create an org team + client.post('/teams/create', data={'name': 'Wipe Team'}, follow_redirects=False) + with app.app_context(): + team_id = OrgTeam.query.filter_by(name='Wipe Team').first().id + + # Add a player to the team + player_id = make_user('player') + client.post( + f'/teams/{team_id}/add_player', + data={'player_id': str(player_id)}, + follow_redirects=False, + ) + + # Create a team match + client.post( + f'/team-matches/{team_id}/create', + data={'title': 'Wipe Match', 'date': '2026-10-01', 'start_time': '10:00'}, + follow_redirects=False, + ) + + return team_id + + def test_wipe_removes_team_players(self, app, client, as_role, seeded_teams): + as_role('admin') + client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False) + + with app.app_context(): + assert TeamPlayer.query.count() == 0 + + def test_wipe_removes_team_matches(self, app, client, as_role, seeded_teams): + as_role('admin') + client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False) + + with app.app_context(): + assert TeamMatch.query.count() == 0 + + def test_wipe_preserves_org_team_structures(self, app, client, as_role, seeded_teams): + as_role('admin') + client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False) + + with app.app_context(): + assert OrgTeam.query.filter_by(name='Wipe Team').first() is not None + + def test_wipe_creates_safety_backup(self, app, client, as_role, seeded_teams): + as_role('admin') + client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False) + + with app.app_context(): + safety = BackupRecord.query.filter_by(backup_type='pre_wipe').first() + assert safety is not None + + def test_wipe_creates_audit_log(self, app, client, as_role, seeded_teams): + as_role('admin') + client.post('/admin/teams/wipe', data={'confirm': 'WIPE'}, follow_redirects=False) + + with app.app_context(): + entry = AuditLog.query.filter_by(action='teams_wiped').first() + assert entry is not None \ No newline at end of file