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 %}
+
+ | No backups yet. Create your first backup above. |
+
{% endfor %}
- {% else %}
-
No backups yet. Create your first backup above.
- {% endif %}
@@ -230,7 +230,6 @@
Audit Log (Recent)
- {% if audit_logs %}
@@ -251,13 +250,14 @@
| {{ entry.details or '—' }} |
{{ entry.ip_address or '—' }} |
+ {% else %}
+
+ | No audit log entries yet. |
+
{% endfor %}
- {% 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