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,132 @@
|
||||
"""Session lifecycle and account state.
|
||||
|
||||
These lock in the two fixes from wave 0: sessions now actually expire, and
|
||||
deactivating an account now closes the sessions it already holds.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _set_active(app, user_id, active):
|
||||
with app.app_context():
|
||||
user = db.session.get(User, user_id)
|
||||
user.is_active_account = active
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestSessionExpiry:
|
||||
def test_login_issues_an_expiring_session_cookie(self, app, client, as_role):
|
||||
"""PERMANENT_SESSION_LIFETIME only applies to permanent sessions.
|
||||
|
||||
Before the fix, session.permanent was never set anywhere in app/,
|
||||
so Flask emitted a browser-session cookie with no Expires attribute
|
||||
and the configured one-hour lifetime was silently ignored.
|
||||
"""
|
||||
as_role('player')
|
||||
|
||||
cookie = client.get_cookie('session')
|
||||
assert cookie is not None, 'no session cookie was issued at login'
|
||||
assert cookie.expires is not None, (
|
||||
'session cookie has no expiry: session.permanent was not set, '
|
||||
'so PERMANENT_SESSION_LIFETIME has no effect'
|
||||
)
|
||||
|
||||
def test_session_lifetime_matches_configuration(self, app):
|
||||
from datetime import timedelta
|
||||
|
||||
assert app.permanent_session_lifetime == timedelta(seconds=3600)
|
||||
|
||||
|
||||
class TestAccountDeactivation:
|
||||
def test_deactivated_account_cannot_log_in(self, app, client, make_user, login):
|
||||
user_id = make_user('player')
|
||||
_set_active(app, user_id, False)
|
||||
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
|
||||
login(username)
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
assert '/auth/login' in response.headers.get('Location', '')
|
||||
|
||||
def test_deactivated_account_loses_its_existing_session(self, app, client, as_role):
|
||||
"""The fix that matters: revocation has to reach live sessions.
|
||||
|
||||
is_active_account used to be consulted only at login. User did not
|
||||
override UserMixin.is_active, so Flask-Login treated every account
|
||||
as active, and disabling someone merely stopped them reconnecting —
|
||||
their open session kept working.
|
||||
"""
|
||||
user_id = as_role('player')
|
||||
|
||||
assert client.get('/users/profile').status_code == 200
|
||||
|
||||
_set_active(app, user_id, False)
|
||||
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302), (
|
||||
'a deactivated account kept access with its existing session'
|
||||
)
|
||||
assert '/auth/login' in response.headers.get('Location', '')
|
||||
|
||||
def test_is_active_property_tracks_the_column(self, app, make_user):
|
||||
user_id = make_user('coach')
|
||||
with app.app_context():
|
||||
user = db.session.get(User, user_id)
|
||||
assert user.is_active is True
|
||||
user.is_active_account = False
|
||||
assert user.is_active is False
|
||||
|
||||
|
||||
class TestLogout:
|
||||
def test_logout_ends_the_session(self, client, as_role):
|
||||
as_role('player')
|
||||
assert client.get('/users/profile').status_code == 200
|
||||
|
||||
client.get('/auth/logout')
|
||||
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
assert '/auth/login' in response.headers.get('Location', '')
|
||||
|
||||
|
||||
class TestLoginRejection:
|
||||
def test_wrong_password_is_refused(self, client, make_user, login, app):
|
||||
user_id = make_user('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
|
||||
login(username, password='WrongPassword1')
|
||||
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason='SEC-AUTH-006: the two branches emit different messages, '
|
||||
'which lets an unauthenticated caller enumerate accounts',
|
||||
)
|
||||
def test_login_failure_message_does_not_reveal_account_existence(
|
||||
self, client, make_user, app
|
||||
):
|
||||
user_id = make_user('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
|
||||
existing = client.post(
|
||||
'/auth/login',
|
||||
data={'username': username, 'password': 'WrongPassword1'},
|
||||
follow_redirects=True,
|
||||
).get_data(as_text=True)
|
||||
|
||||
unknown = client.post(
|
||||
'/auth/login',
|
||||
data={'username': 'no-such-account', 'password': 'WrongPassword1'},
|
||||
follow_redirects=True,
|
||||
).get_data(as_text=True)
|
||||
|
||||
assert ('attempt(s) remaining' in existing) == ('attempt(s) remaining' in unknown)
|
||||
Reference in New Issue
Block a user