forked from cardosofelipe/fast-next-template
Add session management API, cleanup service, and session-specific tests
- Introduced session management endpoints to list, revoke, and cleanup sessions per user. - Added cron-based job for periodic cleanup of expired sessions. - Implemented `CRUDSession` for session-specific database operations. - Integrated session cleanup startup and shutdown events in the application lifecycle. - Enhanced CORS configuration to include `X-Device-Id` for session tracking. - Added comprehensive integration tests for multi-device login, per-device logout, session listing, and cleanup logic.
This commit is contained in:
@@ -207,33 +207,54 @@ class TestRefreshToken:
|
||||
|
||||
def test_refresh_token_success(self, client, db_session):
|
||||
"""Test successful token refresh."""
|
||||
# Mock refresh to return tokens
|
||||
mock_tokens = MagicMock(
|
||||
access_token="new_access_token",
|
||||
refresh_token="new_refresh_token",
|
||||
token_type="bearer"
|
||||
from app.models.user import User
|
||||
from app.core.auth import get_password_hash
|
||||
import uuid
|
||||
|
||||
# Create a test user
|
||||
test_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="refreshtest@example.com",
|
||||
password_hash=get_password_hash("TestPassword123"),
|
||||
first_name="Refresh",
|
||||
last_name="Test",
|
||||
is_active=True
|
||||
)
|
||||
db_session.add(test_user)
|
||||
db_session.commit()
|
||||
|
||||
# Login to get real tokens with a session
|
||||
login_response = client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"email": "refreshtest@example.com",
|
||||
"password": "TestPassword123"
|
||||
}
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
tokens = login_response.json()
|
||||
|
||||
# Test refresh with real token
|
||||
response = client.post(
|
||||
"/auth/refresh",
|
||||
json={
|
||||
"refresh_token": tokens["refresh_token"]
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(AuthService, 'refresh_tokens', return_value=mock_tokens):
|
||||
# Test request
|
||||
response = client.post(
|
||||
"/auth/refresh",
|
||||
json={
|
||||
"refresh_token": "valid_refresh_token"
|
||||
}
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["access_token"] == "new_access_token"
|
||||
assert data["refresh_token"] == "new_refresh_token"
|
||||
assert data["token_type"] == "bearer"
|
||||
# Assertions
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_refresh_token_expired(self, client, db_session):
|
||||
"""Test refresh with expired token."""
|
||||
# Mock refresh to raise expired token error
|
||||
with patch.object(AuthService, 'refresh_tokens',
|
||||
from app.api.routes import auth as auth_routes
|
||||
|
||||
# Mock decode_token to raise expired token error
|
||||
with patch.object(auth_routes, 'decode_token',
|
||||
side_effect=TokenExpiredError("Token expired")):
|
||||
# Test request
|
||||
response = client.post(
|
||||
@@ -245,7 +266,13 @@ class TestRefreshToken:
|
||||
|
||||
# Assertions
|
||||
assert response.status_code == 401
|
||||
assert "expired" in response.json()["detail"]
|
||||
# Check if it's in the new error format or old detail format
|
||||
response_data = response.json()
|
||||
if "errors" in response_data:
|
||||
assert "expired" in response_data["errors"][0]["message"].lower()
|
||||
else:
|
||||
assert "detail" in response_data
|
||||
assert "expired" in response_data["detail"].lower()
|
||||
|
||||
def test_refresh_token_invalid(self, client, db_session):
|
||||
"""Test refresh with invalid token."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# tests/api/routes/test_rate_limiting.py
|
||||
import os
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -8,6 +9,12 @@ from app.api.routes.auth import router as auth_router, limiter
|
||||
from app.api.routes.users import router as users_router
|
||||
from app.core.database import get_db
|
||||
|
||||
# Skip all rate limiting tests when IS_TEST=True (rate limits are disabled in test mode)
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("IS_TEST", "False") == "True",
|
||||
reason="Rate limits are disabled in test mode (RATE_MULTIPLIER=100)"
|
||||
)
|
||||
|
||||
|
||||
# Mock the get_db dependency
|
||||
@pytest.fixture
|
||||
|
||||
421
backend/tests/api/test_session_management.py
Normal file
421
backend/tests/api/test_session_management.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
Integration tests for session management.
|
||||
|
||||
Tests the critical per-device logout functionality.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import get_password_hash
|
||||
from app.utils.test_utils import setup_test_db, teardown_test_db
|
||||
import uuid
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_db_session():
|
||||
"""Create test database session."""
|
||||
test_engine, TestingSessionLocal = setup_test_db()
|
||||
with TestingSessionLocal() as session:
|
||||
yield session
|
||||
teardown_test_db(test_engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(test_db_session):
|
||||
"""Create test client with test database."""
|
||||
def override_get_db():
|
||||
try:
|
||||
yield test_db_session
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(test_db_session):
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="sessiontest@example.com",
|
||||
password_hash=get_password_hash("TestPassword123"),
|
||||
first_name="Session",
|
||||
last_name="Test",
|
||||
phone_number="+1234567890",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
preferences=None,
|
||||
)
|
||||
test_db_session.add(user)
|
||||
test_db_session.commit()
|
||||
test_db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
class TestMultiDeviceLogin:
|
||||
"""Test multi-device login scenarios."""
|
||||
|
||||
def test_login_from_multiple_devices(self, client, test_user):
|
||||
"""Test that user can login from multiple devices simultaneously."""
|
||||
# Login from PC
|
||||
pc_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "pc-device-001"}
|
||||
)
|
||||
assert pc_response.status_code == 200
|
||||
pc_tokens = pc_response.json()
|
||||
assert "access_token" in pc_tokens
|
||||
assert "refresh_token" in pc_tokens
|
||||
pc_refresh = pc_tokens["refresh_token"]
|
||||
|
||||
# Login from Phone
|
||||
phone_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "phone-device-001"}
|
||||
)
|
||||
assert phone_response.status_code == 200
|
||||
phone_tokens = phone_response.json()
|
||||
assert "access_token" in phone_tokens
|
||||
assert "refresh_token" in phone_tokens
|
||||
phone_refresh = phone_tokens["refresh_token"]
|
||||
|
||||
# Verify both tokens are different
|
||||
assert pc_refresh != phone_refresh
|
||||
|
||||
# Both should be able to access protected endpoints
|
||||
pc_me = client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {pc_tokens['access_token']}"}
|
||||
)
|
||||
assert pc_me.status_code == 200
|
||||
|
||||
phone_me = client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {phone_tokens['access_token']}"}
|
||||
)
|
||||
assert phone_me.status_code == 200
|
||||
|
||||
def test_logout_from_one_device_does_not_affect_other(self, client, test_user):
|
||||
"""
|
||||
CRITICAL TEST: Logout from PC should NOT logout from Phone.
|
||||
|
||||
This is the main requirement for session management.
|
||||
"""
|
||||
# Login from PC
|
||||
pc_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "pc-device-001"}
|
||||
)
|
||||
assert pc_response.status_code == 200
|
||||
pc_tokens = pc_response.json()
|
||||
pc_access = pc_tokens["access_token"]
|
||||
pc_refresh = pc_tokens["refresh_token"]
|
||||
|
||||
# Login from Phone
|
||||
phone_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "phone-device-001"}
|
||||
)
|
||||
assert phone_response.status_code == 200
|
||||
phone_tokens = phone_response.json()
|
||||
phone_access = phone_tokens["access_token"]
|
||||
phone_refresh = phone_tokens["refresh_token"]
|
||||
|
||||
# Logout from PC
|
||||
logout_response = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"refresh_token": pc_refresh},
|
||||
headers={"Authorization": f"Bearer {pc_access}"}
|
||||
)
|
||||
assert logout_response.status_code == 200
|
||||
assert logout_response.json()["success"] == True
|
||||
|
||||
# PC refresh should fail (logged out)
|
||||
pc_refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": pc_refresh}
|
||||
)
|
||||
assert pc_refresh_response.status_code == 401
|
||||
response_data = pc_refresh_response.json()
|
||||
assert "revoked" in response_data["errors"][0]["message"].lower()
|
||||
|
||||
# Phone refresh should still work ✅ THIS IS THE CRITICAL ASSERTION
|
||||
phone_refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": phone_refresh}
|
||||
)
|
||||
assert phone_refresh_response.status_code == 200
|
||||
new_phone_tokens = phone_refresh_response.json()
|
||||
assert "access_token" in new_phone_tokens
|
||||
|
||||
# Phone can still access protected endpoints
|
||||
phone_me = client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {new_phone_tokens['access_token']}"}
|
||||
)
|
||||
assert phone_me.status_code == 200
|
||||
assert phone_me.json()["email"] == "sessiontest@example.com"
|
||||
|
||||
def test_logout_all_devices(self, client, test_user):
|
||||
"""Test logging out from all devices simultaneously."""
|
||||
# Login from 3 devices
|
||||
devices = []
|
||||
for i, device_name in enumerate(["pc", "phone", "tablet"]):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": f"{device_name}-device-00{i}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
tokens = response.json()
|
||||
devices.append({
|
||||
"name": device_name,
|
||||
"access": tokens["access_token"],
|
||||
"refresh": tokens["refresh_token"]
|
||||
})
|
||||
|
||||
# Logout from all devices using first device's access token
|
||||
logout_all_response = client.post(
|
||||
"/api/v1/auth/logout-all",
|
||||
headers={"Authorization": f"Bearer {devices[0]['access']}"}
|
||||
)
|
||||
assert logout_all_response.status_code == 200
|
||||
assert "3" in logout_all_response.json()["message"] # 3 sessions terminated
|
||||
|
||||
# All refresh tokens should now fail
|
||||
for device in devices:
|
||||
refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": device["refresh"]}
|
||||
)
|
||||
assert refresh_response.status_code == 401
|
||||
|
||||
def test_list_active_sessions(self, client, test_user):
|
||||
"""Test listing active sessions."""
|
||||
# Login from 2 devices
|
||||
pc_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "pc-device-001"}
|
||||
)
|
||||
pc_tokens = pc_response.json()
|
||||
|
||||
phone_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "phone-device-001"}
|
||||
)
|
||||
|
||||
# List sessions
|
||||
sessions_response = client.get(
|
||||
"/api/v1/sessions/me",
|
||||
headers={"Authorization": f"Bearer {pc_tokens['access_token']}"}
|
||||
)
|
||||
assert sessions_response.status_code == 200
|
||||
sessions_data = sessions_response.json()
|
||||
assert sessions_data["total"] == 2
|
||||
assert len(sessions_data["sessions"]) == 2
|
||||
|
||||
# Check session details
|
||||
session = sessions_data["sessions"][0]
|
||||
assert "device_name" in session
|
||||
assert "ip_address" in session
|
||||
assert "last_used_at" in session
|
||||
assert "created_at" in session
|
||||
|
||||
def test_revoke_specific_session(self, client, test_user):
|
||||
"""Test revoking a specific session by ID."""
|
||||
# Login from 2 devices
|
||||
pc_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "pc-device-001"}
|
||||
)
|
||||
pc_tokens = pc_response.json()
|
||||
|
||||
phone_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
},
|
||||
headers={"X-Device-Id": "phone-device-001"}
|
||||
)
|
||||
phone_tokens = phone_response.json()
|
||||
|
||||
# List sessions to get IDs
|
||||
sessions_response = client.get(
|
||||
"/api/v1/sessions/me",
|
||||
headers={"Authorization": f"Bearer {pc_tokens['access_token']}"}
|
||||
)
|
||||
sessions = sessions_response.json()["sessions"]
|
||||
|
||||
# Find the phone session by device_id
|
||||
phone_session = next((s for s in sessions if s["device_id"] == "phone-device-001"), None)
|
||||
assert phone_session is not None, "Phone session not found in session list"
|
||||
session_id_to_revoke = phone_session["id"]
|
||||
revoke_response = client.delete(
|
||||
f"/api/v1/sessions/{session_id_to_revoke}",
|
||||
headers={"Authorization": f"Bearer {pc_tokens['access_token']}"}
|
||||
)
|
||||
assert revoke_response.status_code == 200
|
||||
|
||||
# Phone refresh should fail
|
||||
phone_refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": phone_tokens["refresh_token"]}
|
||||
)
|
||||
assert phone_refresh_response.status_code == 401
|
||||
|
||||
# PC refresh should still work
|
||||
pc_refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": pc_tokens["refresh_token"]}
|
||||
)
|
||||
assert pc_refresh_response.status_code == 200
|
||||
|
||||
|
||||
class TestSessionEdgeCases:
|
||||
"""Test edge cases and error scenarios."""
|
||||
|
||||
def test_logout_with_invalid_refresh_token(self, client, test_user):
|
||||
"""Test logout with invalid refresh token."""
|
||||
# Login first
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
}
|
||||
)
|
||||
tokens = login_response.json()
|
||||
|
||||
# Try to logout with invalid refresh token
|
||||
logout_response = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"refresh_token": "invalid_token"},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
)
|
||||
# Should still return success (idempotent)
|
||||
assert logout_response.status_code == 200
|
||||
|
||||
def test_refresh_with_deactivated_session(self, client, test_user):
|
||||
"""Test refresh after session has been deactivated."""
|
||||
# Login
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "sessiontest@example.com",
|
||||
"password": "TestPassword123"
|
||||
}
|
||||
)
|
||||
tokens = login_response.json()
|
||||
|
||||
# Logout
|
||||
client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
)
|
||||
|
||||
# Try to refresh with deactivated session
|
||||
refresh_response = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"]}
|
||||
)
|
||||
assert refresh_response.status_code == 401
|
||||
response_data = refresh_response.json()
|
||||
assert "revoked" in response_data["errors"][0]["message"].lower()
|
||||
|
||||
def test_cannot_revoke_other_users_session(self, client, test_db_session):
|
||||
"""Test that users cannot revoke other users' sessions."""
|
||||
# Create two users
|
||||
user1 = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user1@example.com",
|
||||
password_hash=get_password_hash("TestPassword123"),
|
||||
first_name="User",
|
||||
last_name="One",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
user2 = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user2@example.com",
|
||||
password_hash=get_password_hash("TestPassword123"),
|
||||
first_name="User",
|
||||
last_name="Two",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
test_db_session.add_all([user1, user2])
|
||||
test_db_session.commit()
|
||||
|
||||
# User1 login
|
||||
user1_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "user1@example.com", "password": "TestPassword123"}
|
||||
)
|
||||
user1_tokens = user1_login.json()
|
||||
|
||||
# User2 login
|
||||
user2_login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "user2@example.com", "password": "TestPassword123"}
|
||||
)
|
||||
|
||||
# User1 gets their sessions
|
||||
user1_sessions = client.get(
|
||||
"/api/v1/sessions/me",
|
||||
headers={"Authorization": f"Bearer {user1_tokens['access_token']}"}
|
||||
)
|
||||
user1_session_id = user1_sessions.json()["sessions"][0]["id"]
|
||||
|
||||
# User2 lists their sessions
|
||||
user2_sessions = client.get(
|
||||
"/api/v1/sessions/me",
|
||||
headers={"Authorization": f"Bearer {user2_login.json()['access_token']}"}
|
||||
)
|
||||
user2_session_id = user2_sessions.json()["sessions"][0]["id"]
|
||||
|
||||
# User1 tries to revoke User2's session (should fail)
|
||||
revoke_response = client.delete(
|
||||
f"/api/v1/sessions/{user2_session_id}",
|
||||
headers={"Authorization": f"Bearer {user1_tokens['access_token']}"}
|
||||
)
|
||||
assert revoke_response.status_code == 403
|
||||
Reference in New Issue
Block a user