| """ |
| Security Tests for User Logout Flow |
| |
| Tests comprehensive logout scenarios including: |
| - Valid logout with authenticated user |
| - Logout with invalid token |
| - Logout with expired token |
| - Session cleanup on logout |
| """ |
|
|
| |
| import os |
| os.environ["TESTING"] = "1" |
|
|
| import pytest |
| from datetime import timedelta |
| from sqlalchemy.orm import Session |
| from core.models import User, RevokedToken |
| from core.auth import get_password_hash, create_access_token, SECRET_KEY |
| from jose import jwt |
| import time |
|
|
|
|
| class TestLogoutWithValidToken: |
| """Test user logout with valid authentication""" |
|
|
| def test_logout_with_valid_token(self, client, db_session: Session): |
| """Test logout succeeds with valid authentication token""" |
| |
| user = User( |
| email="logout@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="Log", |
| last_name="Out", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login_response = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "logout@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token = login_response.json()["access_token"] |
|
|
| |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert "success" in data or "message" in data |
|
|
| def test_logout_clears_client_token(self, client, db_session: Session): |
| """Test logout instructs client to discard token""" |
| user = User( |
| email="cleartoken@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="Clear", |
| last_name="Token", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login_response = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "cleartoken@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token = login_response.json()["access_token"] |
|
|
| |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| assert response.status_code == 200 |
|
|
| |
| |
| |
| me_response = client.get( |
| "/api/auth/me", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
| |
| assert me_response.status_code in [200, 401] |
|
|
|
|
| class TestLogoutWithInvalidToken: |
| """Test logout fails with invalid authentication""" |
|
|
| def test_logout_with_missing_token(self, client): |
| """Test logout fails without authentication token""" |
| response = client.post("/api/auth/logout") |
|
|
| |
| assert response.status_code in [401, 403] |
|
|
| def test_logout_with_malformed_token(self, client): |
| """Test logout fails with malformed JWT token""" |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": "Bearer invalid.jwt.token"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_logout_with_empty_token(self, client): |
| """Test logout fails with empty bearer token""" |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": "Bearer "} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
|
|
| class TestLogoutWithExpiredToken: |
| """Test logout with expired token""" |
|
|
| def test_logout_with_expired_token(self, client, db_session: Session): |
| """Test logout fails with expired token""" |
| |
| user = User( |
| email="expired@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="Exp", |
| last_name="ired", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| import time |
| expired_time = int(time.time()) - 3600 |
|
|
| |
| from jose import jwt |
| payload = { |
| "sub": str(user.id), |
| "exp": expired_time |
| } |
| expired_token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") |
|
|
| |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {expired_token}"} |
| ) |
|
|
| |
| assert response.status_code == 401 |
|
|
|
|
| class TestLogoutTokenRevocation: |
| """Test token revocation on logout (if implemented)""" |
|
|
| def test_logout_revokes_token_in_database(self, client, db_session: Session): |
| """Test logout adds token to revoked tokens list""" |
| user = User( |
| email="revoke@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="Rev", |
| last_name="oke", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login_response = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "revoke@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token = login_response.json()["access_token"] |
|
|
| |
| try: |
| payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) |
| jti = payload.get("jti") |
| except: |
| jti = None |
|
|
| |
| response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| assert response.status_code == 200 |
|
|
| |
| |
| if jti: |
| revoked = db_session.query(RevokedToken).filter_by(jti=jti).first() |
| |
| |
|
|
|
|
| class TestLogoutMultipleSessions: |
| """Test logout with multiple active sessions""" |
|
|
| def test_logout_only_affects_current_session(self, client, db_session: Session): |
| """Test logout doesn't invalidate other session tokens""" |
| user = User( |
| email="multisession@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="Multi", |
| last_name="Session", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login1 = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "multisession@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token1 = login1.json()["access_token"] |
|
|
| login2 = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "multisession@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token2 = login2.json()["access_token"] |
|
|
| |
| logout_response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token1}"} |
| ) |
| assert logout_response.status_code == 200 |
|
|
| |
| me_response = client.get( |
| "/api/auth/me", |
| headers={"Authorization": f"Bearer {token2}"} |
| ) |
| |
| assert me_response.status_code in [200, 401] |
|
|
|
|
| class TestLogoutSecurity: |
| """Test security aspects of logout""" |
|
|
| def test_logout_prevents_csrf(self, client, db_session: Session): |
| """Test logout endpoint is protected against CSRF""" |
| user = User( |
| email="csrf@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="CS", |
| last_name="RF", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login_response = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "csrf@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token = login_response.json()["access_token"] |
|
|
| |
| response = client.get( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| |
| |
| assert response.status_code in [405, 404] |
|
|
| def test_logout_no_sensitive_data_in_response(self, client, db_session: Session): |
| """Test logout response doesn't leak sensitive data""" |
| user = User( |
| email="leak@example.com", |
| password_hash=get_password_hash("SecurePass123!"), |
| first_name="No", |
| last_name="Leak", |
| status="active" |
| ) |
| db_session.add(user) |
| db_session.commit() |
|
|
| |
| login_response = client.post( |
| "/api/auth/login", |
| json={ |
| "username": "leak@example.com", |
| "password": "SecurePass123!" |
| } |
| ) |
| token = login_response.json()["access_token"] |
|
|
| |
| logout_response = client.post( |
| "/api/auth/logout", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| assert logout_response.status_code == 200 |
| data = logout_response.json() |
|
|
| |
| assert "password" not in str(data).lower() |
| assert "token" not in data or "access_token" not in data |
| assert "secret" not in str(data).lower() |
|
|