This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.models import (
|
||||
BackupRecord,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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}')
|
||||
Reference in New Issue
Block a user