"""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)