test: socle de tests executables et fabrique d'application parametrable
Il n'existait aucun test, et le code n'offrait aucune prise pour en ecrire :
create_app() exigeait SECRET_KEY et DATABASE_URL dans l'environnement,
creait les tables et demarrait un bot Discord. C'etait la cause, pas le
symptome.
create_app(config=None)
Les valeurs par defaut viennent toujours de l'environnement, les
surcharges de l'appelant sont appliquees ensuite, et la validation
vient en dernier pour qu'un test puisse fournir les siennes. Deux
effets de bord passent sous drapeau, actifs par defaut pour que la
production et le developpement se comportent a l'identique :
AUTO_CREATE_TABLES controle db.create_all()
ENABLE_DISCORD_BOT controle start_bot()
FORCE_HTTPS passe egalement en configuration : lu via os.getenv a
chaque requete, il renvoyait un 301 sur tout appel de test.
Suite de tests : 47 tests, 3 xfail, 32 % de couverture.
tests/conftest.py fabriques par role, connexion par le vrai
formulaire, base SQLite temporaire
test_auth_session.py expiration de session, desactivation de
compte, deconnexion
test_security_headers.py en-tetes, non-divulgation sur /health,
echappement de nl2br
test_authorization.py acces anonyme, vertical, horizontal,
validation des entrees, CSRF
Les tests marques xfail(strict=True) decrivent des constats non encore
corriges. Ils echouent par construction ; le mode strict transforme une
reussite inattendue en echec, ce qui signale qu'il faut retirer le
marqueur. Trois subsistent : enumeration de comptes (SEC-AUTH-006), CSP
unsafe-inline (SEC-WEB-001), auto-retrogradation du dernier administrateur
(SEC-AUTHZ-007).
pyproject.toml
Configuration pytest et ruff. Ruff n'avait aucune configuration : la CI
l'executait avec le jeu de regles par defaut. Les 33 F401 de
app/models/__init__.py sont ignores par fichier, c'est une facade de
re-export intentionnelle.
requirements-dev.txt separe l'outillage de test des dependances de
production.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""Shared pytest fixtures for the Team Tryouts test suite.
|
||||
|
||||
The application factory is driven entirely through the ``config`` argument
|
||||
here: no environment variable is required to run the suite, no database
|
||||
server is needed, and the Discord bot never starts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Make the project root importable as the 'app' package.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.app import create_app # noqa: E402
|
||||
from app.extensions import db as _db # noqa: E402
|
||||
from app.models import Admin, Coach, Manager, Player, Scout # noqa: E402
|
||||
|
||||
|
||||
ROLE_CLASSES = {
|
||||
'admin': Admin,
|
||||
'manager': Manager,
|
||||
'coach': Coach,
|
||||
'player': Player,
|
||||
'scout': Scout,
|
||||
}
|
||||
|
||||
#: Satisfies the documented policy (8+ chars, upper, lower, digit).
|
||||
VALID_PASSWORD = 'Password123'
|
||||
|
||||
|
||||
def _base_config(db_path, csrf=False):
|
||||
return {
|
||||
'SECRET_KEY': 'test-secret-not-used-anywhere-real',
|
||||
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
|
||||
'TESTING': True,
|
||||
'WTF_CSRF_ENABLED': csrf,
|
||||
# Without these three the suite would 301 every request, start a
|
||||
# Discord bot, and refuse to issue cookies over the test client.
|
||||
'FORCE_HTTPS': False,
|
||||
'SESSION_COOKIE_SECURE': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
# The schema still comes from create_all() until Alembic lands (DB-002).
|
||||
'AUTO_CREATE_TABLES': True,
|
||||
'CORS_ALLOWED_ORIGINS': '',
|
||||
'RATELIMIT_ENABLED': False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""A fully configured application backed by a throwaway SQLite file.
|
||||
|
||||
A file rather than :memory: because Flask-SQLAlchemy hands out a
|
||||
connection per thread, and an in-memory database is not shared between
|
||||
them — tables created on one connection would be invisible to the next.
|
||||
"""
|
||||
fd, db_path = tempfile.mkstemp(suffix='.sqlite')
|
||||
os.close(fd)
|
||||
|
||||
application = create_app(_base_config(db_path))
|
||||
|
||||
yield application
|
||||
|
||||
with application.app_context():
|
||||
_db.session.remove()
|
||||
_db.engine.dispose()
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_csrf():
|
||||
"""Same application, with CSRF protection left switched on."""
|
||||
fd, db_path = tempfile.mkstemp(suffix='.sqlite')
|
||||
os.close(fd)
|
||||
|
||||
application = create_app(_base_config(db_path, csrf=True))
|
||||
|
||||
yield application
|
||||
|
||||
with application.app_context():
|
||||
_db.session.remove()
|
||||
_db.engine.dispose()
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(app):
|
||||
"""Database handle bound to an active application context."""
|
||||
with app.app_context():
|
||||
yield _db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_user(app):
|
||||
"""Factory creating a user of a given role and returning its id.
|
||||
|
||||
Returns the primary key rather than the instance: the object would be
|
||||
detached once the fixture's application context is popped, and every
|
||||
caller wants to look it up inside its own context anyway.
|
||||
"""
|
||||
counter = {'n': 0}
|
||||
|
||||
def _make(role='player', password=VALID_PASSWORD, **kwargs):
|
||||
from app.extensions import hash_password
|
||||
|
||||
counter['n'] += 1
|
||||
n = counter['n']
|
||||
cls = ROLE_CLASSES[role]
|
||||
with app.app_context():
|
||||
user = cls(
|
||||
username=kwargs.pop('username', f'{role}{n}'),
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
full_name=kwargs.pop('full_name', f'{role.title()} {n}'),
|
||||
email=kwargs.pop('email', f'{role}{n}@example.test'),
|
||||
**kwargs,
|
||||
)
|
||||
_db.session.add(user)
|
||||
_db.session.commit()
|
||||
return user.id
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login(client):
|
||||
"""Log a user in through the real login form.
|
||||
|
||||
Deliberately exercises the actual authentication path rather than
|
||||
poking flask_login's session key, so that session handling itself
|
||||
stays under test.
|
||||
"""
|
||||
|
||||
def _login(username, password=VALID_PASSWORD):
|
||||
return client.post(
|
||||
'/auth/login',
|
||||
data={'username': username, 'password': password},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
return _login
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def as_role(app, client, make_user, login):
|
||||
"""Create a user of the given role, log in, and return its id."""
|
||||
|
||||
def _as(role='player', **kwargs):
|
||||
user_id = make_user(role, **kwargs)
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
username = _db.session.get(User, user_id).username
|
||||
response = login(username)
|
||||
assert response.status_code in (301, 302), (
|
||||
f'login for {username} did not redirect: {response.status_code}'
|
||||
)
|
||||
return user_id
|
||||
|
||||
return _as
|
||||
Reference in New Issue
Block a user