This commit is contained in:
@@ -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')
|
||||
|
||||
+36
-34
@@ -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()]
|
||||
|
||||
|
||||
@@ -174,7 +174,6 @@
|
||||
<h3><i class="fas fa-history"></i> Backup History & Restore</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if backups %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -213,13 +212,14 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +230,6 @@
|
||||
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if audit_logs %}
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -251,13 +250,14 @@
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
|
||||
<td>{{ entry.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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:[email protected]: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'
|
||||
@@ -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='[email protected]',
|
||||
)
|
||||
_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'
|
||||
)
|
||||
@@ -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 '<!DOCTYPE html>' in html or '<!doctype html>' 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 <form> tags and csrf_token occurrences
|
||||
form_count = html.count('<form ')
|
||||
csrf_count = html.count('csrf_token')
|
||||
assert form_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
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user