ajout de tests
CI - Security, Lint & Tests / validate (push) Failing after 56s

This commit is contained in:
cedrick2711
2026-08-25 15:53:46 -04:00
parent 1549fbaef3
commit d18979a3e9
14 changed files with 1253 additions and 42 deletions
+9
View File
@@ -120,6 +120,10 @@ def list_matches():
def create_match(team_id): def create_match(team_id):
"""Create a new regular-season team match.""" """Create a new regular-season team match."""
team = db.get_or_404(OrgTeam, team_id) 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): if not can_manage_team_match(team):
flash(_('You do not have permission to schedule matches for this team.'), 'danger') flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
@@ -272,9 +276,14 @@ def delete_match(match_id):
"""Delete a team match.""" """Delete a team match."""
team_match = db.get_or_404(TeamMatch, match_id) team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team 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): if not can_manage_team_match(team):
flash(_('You do not have permission to delete this match.'), 'danger') flash(_('You do not have permission to delete this match.'), 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash(_('Match deleted successfully.'), 'success') flash(_('Match deleted successfully.'), 'success')
+36 -34
View File
@@ -139,12 +139,12 @@ def list_tryouts():
@login_required @login_required
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if not can_manage(): if tryouts_locked():
flash(_('You do not have permission to create tryouts.'), 'danger') flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
if tryouts_locked(): if not can_manage():
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') flash(_('You do not have permission to create tryouts.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() 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.""" """Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = db.get_or_404(Tryout, tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to edit this tryout.'), 'danger') flash(_('You do not have permission to edit this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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: if tryout.is_ended:
flash(_('This tryout has ended and can no longer be modified.'), 'danger') flash(_('This tryout has ended and can no longer be modified.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) 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): def update_status(tryout_id):
"""Update the status of a tryout.""" """Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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: try:
data = TryoutStatusSchema().load(form_payload(list_fields=())) data = TryoutStatusSchema().load(form_payload(list_fields=()))
except ValidationError as err: except ValidationError as err:
@@ -522,14 +523,14 @@ def update_status(tryout_id):
def update_registration_status(tryout_id, player_id): def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status.""" """Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id tryout_id=tryout_id, player_id=player_id
).first_or_404() ).first_or_404()
@@ -550,12 +551,13 @@ def update_registration_status(tryout_id, player_id):
def register_player(tryout_id): def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_or_404(tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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: try:
data = PlayerSelectionSchema().load(form_payload()) data = PlayerSelectionSchema().load(form_payload())
except ValidationError as err: except ValidationError as err:
@@ -600,14 +602,14 @@ def register_player(tryout_id):
def remove_player(tryout_id, player_id): def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('Permission denied.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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) player = db.get_or_404(User, player_id)
registration = TryoutRegistration.query.filter_by( registration = TryoutRegistration.query.filter_by(
@@ -640,12 +642,12 @@ def remove_player(tryout_id, player_id):
def create_team(tryout_id): def create_team(tryout_id):
"""Create a tryout-specific team.""" """Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if tryouts_locked():
flash(_('Permission denied.'), 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked(): if not current_user.can_manage_this_tryout(tryout):
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
try: try:
@@ -667,12 +669,12 @@ def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team.""" """Add a player to a tryout team."""
team = db.get_or_404(Team, team_id) team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if not current_user.can_manage_this_tryout(tryout): if tryouts_locked():
flash(_('Permission denied.'), 'danger') 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryouts_locked(): if not current_user.can_manage_this_tryout(tryout):
flash(_('Tryouts are currently closed. An admin must open tryouts before changes can be made.'), 'danger') flash(_('Permission denied.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being # 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): def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash(_('You do not have permission to delete this tryout.'), 'danger') flash(_('You do not have permission to delete this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) 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()] 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()] team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
+8 -8
View File
@@ -174,7 +174,6 @@
<h3><i class="fas fa-history"></i> Backup History & Restore</h3> <h3><i class="fas fa-history"></i> Backup History & Restore</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if backups %}
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table">
<thead> <thead>
@@ -213,13 +212,14 @@
</form> </form>
</td> </td>
</tr> </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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No backups yet. Create your first backup above.</p>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -230,7 +230,6 @@
<h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3> <h3><i class="fas fa-clipboard-list"></i> Audit Log (Recent)</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if audit_logs %}
<div class="table-container"> <div class="table-container">
<table class="table"> <table class="table">
<thead> <thead>
@@ -251,13 +250,14 @@
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td> <td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ entry.details or '—' }}</td>
<td>{{ entry.ip_address or '—' }}</td> <td>{{ entry.ip_address or '—' }}</td>
</tr> </tr>
{% else %}
<tr>
<td colspan="5" class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<p class="text-muted" style="font-size: 0.85rem;">No audit log entries yet.</p>
{% endif %}
</div> </div>
</div> </div>
+5
View File
@@ -194,6 +194,11 @@ def as_role(app, client, make_user, login):
from app.models import User from app.models import User
username = _db.session.get(User, user_id).username 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) response = login(username)
assert response.status_code in (301, 302), ( assert response.status_code in (301, 302), (
f'login for {username} did not redirect: {response.status_code}' f'login for {username} did not redirect: {response.status_code}'
+62
View File
@@ -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'
+61
View File
@@ -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
+285
View File
@@ -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'
+56
View File
@@ -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'
)
+150
View File
@@ -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
+49
View File
@@ -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'
)
+170
View File
@@ -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
+87
View File
@@ -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
+156
View File
@@ -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
+119
View File
@@ -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