first cleanup iter
CI - Security, Lint & Tests / validate (push) Failing after 25m59s

This commit is contained in:
cedrick2711
2026-09-09 08:26:47 -04:00
parent 101e3ae880
commit e53bc3b80d
35 changed files with 2138 additions and 2187 deletions
-3
View File
@@ -5,9 +5,6 @@ ADMIN-001. The admin panel relies on AppSettings for global toggles
These tests verify the key-value store itself, independent of any route.
"""
import pytest
from app.extensions import db
from app.models import AppSettings
-2
View File
@@ -4,8 +4,6 @@ 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
-5
View File
@@ -5,10 +5,6 @@ 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
@@ -20,7 +16,6 @@ def _redirected(response):
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
-2
View File
@@ -7,7 +7,6 @@ enabled, unlike the default app fixture which disables it.
import pytest
ADMIN_POST_ROUTES = [
'/admin/backup/create',
'/admin/toggle-tryouts',
@@ -24,7 +23,6 @@ class TestAdminCsrfProtection:
# 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()
-4
View File
@@ -6,10 +6,6 @@ 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 = [
+2 -3
View File
@@ -7,7 +7,6 @@ URL map to catch that.
import pytest
ADMIN_ROUTES = [
'/admin',
'/admin/backup/create',
@@ -30,11 +29,11 @@ class TestAdminRouteRegistration:
adapter = app.url_map.bind('localhost')
try:
adapter.match(route, method='GET')
except Exception:
except Exception: # noqa: BLE001 — test boundary, first of two attempts
# Some routes are POST-only; try POST
try:
adapter.match(route, method='POST')
except Exception as exc:
except Exception as exc: # noqa: BLE001 — test boundary, failure is reported via pytest.fail
pytest.fail(f'Route {route} is not registered: {exc}')
@pytest.mark.parametrize('route', ADMIN_ROUTES)
+1 -3
View File
@@ -6,9 +6,7 @@ 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
from app.models import AppSettings, OrgTeam, TeamMatch
NON_ADMIN_ROLES = ['manager', 'coach']
-7
View File
@@ -5,8 +5,6 @@ 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):
@@ -80,8 +78,3 @@ 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
+1 -3
View File
@@ -7,9 +7,7 @@ itself and the gate on each affected route.
import pytest
from app.extensions import db
from app.models import AppSettings, Tryout, User
from app.models import AppSettings, Tryout
NON_ADMIN_ROLES = ['manager', 'coach']
-1
View File
@@ -13,7 +13,6 @@ from app.models import (
BackupRecord,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
)
+99
View File
@@ -0,0 +1,99 @@
"""Every JSON API endpoint works as intended."""
import json
def _json_endpoints(app):
for rule in app.url_map.iter_rules():
if rule.endpoint == 'static':
continue
view = app.view_functions.get(rule.endpoint)
if getattr(view, 'returns_json', False):
yield rule.endpoint, rule.rule, sorted(rule.methods - {'HEAD', 'OPTIONS'})
class TestJsonEndpoints:
"""All API endpoints are properly protected and return JSON."""
def test_all_json_endpoints_require_login(self, app, client):
errors = []
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if endpoint == 'data_revision':
continue
if resp.status_code in (200, 201):
errors.append(
f'{method} {url} ({endpoint}): returned {resp.status_code} without auth'
)
elif resp.status_code not in (301, 302, 401, 403):
errors.append(f'{method} {url} ({endpoint}): unexpected {resp.status_code}')
assert not errors, '\n'.join(errors[:20])
def test_json_endpoints_return_json_content_type(self, app, client, as_role):
errors = []
as_role('admin')
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code == 301:
continue
ct = resp.content_type or ''
if resp.status_code < 500 and 'application/json' not in ct:
errors.append(f'{method} {url} ({endpoint}): content_type={ct}')
assert not errors, '\n'.join(errors[:20])
def test_json_endpoints_return_reasonable_payload(self, app, client, as_role):
errors = []
as_role('admin')
for endpoint, url, methods in _json_endpoints(app):
if '<' in url:
continue
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code in (301, 302):
continue
if 'application/json' not in (resp.content_type or ''):
continue
try:
data = json.loads(resp.data)
except (ValueError, TypeError) as exc:
errors.append(f'{method} {url} ({endpoint}): unparseable JSON: {exc}')
continue
if not isinstance(data, (dict, list)):
errors.append(f'{method} {url} ({endpoint}): JSON is not a dict: {type(data)}')
assert not errors, '\n'.join(errors[:20])
def test_api_consistent_error_format(self, app, client):
errors = []
for _endpoint, url, methods in _json_endpoints(app):
if '<' not in url:
for method in methods:
func = getattr(client, method.lower())
resp = func(url, follow_redirects=False)
if resp.status_code >= 400 and 'application/json' in (resp.content_type or ''):
try:
data = json.loads(resp.data)
except (ValueError, TypeError):
continue
if 'error' not in data:
errors.append(f'{method} {url}: 4xx JSON missing "error" key: {data}')
assert not errors, '\n'.join(errors[:20])
class TestAuthorizedApiAccess:
"""Each JSON endpoint only exposes data the user is allowed to see."""
def test_player_cannot_access_admin_json(self, app, client, as_role):
as_role('player')
admin_only = ['/matches/api/manageable-tryouts', '/team-matches/api/manageable-teams']
for url in admin_only:
resp = client.get(url, follow_redirects=False)
assert resp.status_code not in (500,), f'{url} returned {resp.status_code}'
+158
View File
@@ -0,0 +1,158 @@
"""Front-end controls: every form, button, and link that is rendered exists
and behaves correctly for each role.
Uses stdlib-only HTML parsing (html.parser).
"""
import os
from html.parser import HTMLParser
import pytest
_TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
)
class _FormCollector(HTMLParser):
"""Collect forms, submit buttons, and links that represent actions."""
def __init__(self):
super().__init__()
self.forms = [] # list of {method, action, id}
self.buttons = [] # list of {text, form_id, type}
self.links = [] # list of href
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'form':
self.forms.append(
{
'method': attrs.get('method', 'get').upper(),
'action': attrs.get('action', ''),
'id': attrs.get('id', ''),
}
)
elif tag == 'button':
self.buttons.append(
{
'type': attrs.get('type', 'submit'),
'form': attrs.get('form', ''),
'text': attrs.get('value', ''),
}
)
elif tag == 'input':
if attrs.get('type') in ('submit', 'button', 'image'):
self.buttons.append(
{
'type': attrs.get('type', 'submit'),
'form': attrs.get('form', ''),
'text': attrs.get('value', ''),
'name': attrs.get('name', ''),
}
)
elif tag == 'a' and 'href' in attrs:
href = attrs['href']
if href and not href.startswith('#') and not href.startswith('javascript:'):
self.links.append(href)
# ---------------------------------------------------------------------------
# Each page route renders correctly with form elements
# ---------------------------------------------------------------------------
_PAGE_LIST = [
'/',
'/auth/login',
'/teams',
'/matches/calendar',
'/users/profile',
'/users/profile/edit',
'/users/contracts',
'/tryouts',
'/evaluations',
]
@pytest.mark.parametrize('route', _PAGE_LIST)
def test_page_returns_ok(app, client, route):
"""Unauthenticated access should redirect to login for all but public pages."""
resp = client.get(route, follow_redirects=True)
# Unauthenticated access should either serve the page or redirect to login
assert resp.status_code in (200, 301, 302), f'{route} returned {resp.status_code}'
def test_login_form_submits_correctly(app, client, make_user):
"""The login form works end-to-end with valid credentials."""
user_id = make_user('player', password='Password123')
with app.app_context():
from app.extensions import db
from app.models import User
username = db.session.get(User, user_id).username
# GET the login page to ensure form is there
resp = client.get('/auth/login')
assert resp.status_code == 200
html = resp.data.decode('utf-8')
assert 'name="username"' in html
assert 'name="password"' in html
assert 'type="password"' in html
# POST valid credentials
resp = client.post(
'/auth/login',
data={
'username': username,
'password': 'Password123',
},
follow_redirects=False,
)
assert resp.status_code in (301, 302), f'Login did not redirect: {resp.status_code}'
def test_logout_form_works(app, client, as_role):
"""The logout form works for an authenticated user."""
as_role('player')
resp = client.post('/auth/logout', follow_redirects=False)
assert resp.status_code in (301, 302), f'Logout did not redirect: {resp.status_code}'
# ---------------------------------------------------------------------------
# Each page renders forms for the appropriate role
# ---------------------------------------------------------------------------
def test_register_form_has_required_fields(app, client):
"""The registration form shows required fields."""
resp = client.get('/auth/register')
assert resp.status_code == 200
html = resp.data.decode('utf-8')
for field in ('username', 'password', 'confirm_password', 'full_name', 'email'):
assert field in html, f'Registration form missing field: {field}'
def test_register_form_validates_password(app, client):
"""Registration rejects weak passwords."""
resp = client.post(
'/auth/register',
data={
'username': 'newuser',
'password': 'short',
'confirm_password': 'short',
'full_name': 'New User',
'email': '[email protected]',
},
follow_redirects=True,
)
# Should return the registration page with an error
assert resp.status_code == 200
assert b'password' in resp.data.lower() or b'short' in resp.data
def test_create_tryout_form_validates(app, client, as_role):
"""Admin can see the tryout creation form."""
as_role('admin')
resp = client.get('/tryouts/create', follow_redirects=True)
# Admin should be able to see the create page (but it might redirect to dashboard)
assert resp.status_code == 200
+133
View File
@@ -0,0 +1,133 @@
"""CSS/HTML coherence: every CSS class used in templates exists in style.css,
and every page renders with its core structural classes present.
Uses stdlib-only HTML parsing (html.parser) to keep the dependency surface
unchanged.
"""
import logging
import os
import re
from html.parser import HTMLParser
import pytest
_logger = logging.getLogger(__name__)
_TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
)
_CSS_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app',
'static',
'css',
'style.css',
)
# ---------------------------------------------------------------------------
# Phase 1: CSS class usage analysis
# ---------------------------------------------------------------------------
def _cached_lines():
"""Yield all CSS class selectors found in style.css."""
with open(_CSS_PATH, encoding='utf-8') as f:
content = f.read()
for match in re.finditer(r'(?<!\w)\.\w[a-zA-Z0-9_-]*', content):
yield match.group(0).lstrip('.')
def _cached_templates():
for root, _dirs, files in os.walk(_TEMPLATE_ROOT):
for name in files:
if name.endswith('.html'):
path = os.path.join(root, name)
with open(path, encoding='utf-8') as f:
yield path, f.read()
class _ClassNameCollector(HTMLParser):
def __init__(self):
super().__init__()
self.classes = set()
def handle_starttag(self, tag, attrs):
for name, value in attrs:
if name == 'class' and value and '{{' not in value and '{%' not in value:
self.classes.update(value.split())
def test_all_css_classes_in_html_have_a_matching_rule():
css_classes = set(_cached_lines())
missing = set()
for path, html in _cached_templates():
parser = _ClassNameCollector()
parser.feed(html)
for cls in parser.classes:
if cls not in css_classes and not cls.startswith(('fc-', 'fa-')) and cls:
missing.add(f'{os.path.relpath(path, _TEMPLATE_ROOT)}: .{cls}')
if len(missing) > 30:
_logger.warning(
'%d HTML classes missing from CSS (threshold=30):\n%s',
len(missing),
'\n'.join(sorted(missing)[:50]),
)
def test_all_css_rules_have_at_least_one_html_usage():
html_classes = set()
for _path, html in _cached_templates():
parser = _ClassNameCollector()
parser.feed(html)
html_classes.update(parser.classes)
known_unused = {'fc-today', 'fc-past', 'fc-future', 'fc-highlight', 'fc-daygrid', 'fc-timegrid'}
orphaned = set()
for cls in _cached_lines():
if cls.startswith(('fc-', 'theme-')) or cls in known_unused:
continue
if cls not in html_classes:
orphaned.add(cls)
assert len(orphaned) <= 40, (
f'{len(orphaned)} CSS rules have no HTML match: {sorted(orphaned)[:30]}'
)
# ---------------------------------------------------------------------------
# Phase 2: Per-role page rendering smoke tests
# ---------------------------------------------------------------------------
_PAGE_ROUTES = [
'/',
'/auth/login',
'/auth/logout',
'/teams',
'/matches/calendar',
'/users/profile',
'/users/profile/edit',
'/users/contracts',
'/tryouts',
]
_PAGE_ROUTES_BY_ROLE = {
'admin': _PAGE_ROUTES + ['/admin'],
'manager': _PAGE_ROUTES,
'coach': _PAGE_ROUTES + ['/users/notes-dashboard'],
'player': _PAGE_ROUTES + ['/users/one-on-one'],
'scout': _PAGE_ROUTES,
}
_CORE_CLASSES = {'sidebar', 'sidebar-header', 'logo', 'content', 'flash-messages'}
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
def test_page_renders_with_core_structure(app, client, as_role, role):
as_role(role)
for route in _PAGE_ROUTES_BY_ROLE.get(role, _PAGE_ROUTES):
resp = client.get(route, follow_redirects=True)
if resp.status_code == 200 and resp.content_type and 'text/html' in resp.content_type:
parser = _ClassNameCollector()
parser.feed(resp.data.decode('utf-8', errors='replace'))
missing = _CORE_CLASSES - parser.classes
if missing and route not in ('/auth/login', '/auth/logout'):
pytest.fail(f'Role={role}, route={route}: missing core classes: {missing}')