"""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 re import pytest from app.extensions import db from app.models import User #: Matches the flash markup emitted by layouts/base.html. _FLASH_PATTERN = re.compile( r'
\s*(.*?)', re.S, ) def _flash_messages(body): """Flash texts rendered in a response, independent of the locale.""" return [m.strip() for m in _FLASH_PATTERN.findall(body)] 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 ): """Compares the rendered flash messages rather than looking for a known substring: the site is served in French by default, so an English marker would silently match nothing on both sides and make the test pass while the flaw is still there.""" user_id = make_user('player') with app.app_context(): username = db.session.get(User, user_id).username def failure_message(name): body = client.post( '/auth/login', data={'username': name, 'password': 'WrongPassword1'}, follow_redirects=True, ).get_data(as_text=True) return _flash_messages(body) assert failure_message(username) == failure_message('no-such-account')