- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest). - Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting. - Updated `requirements.txt` to include Ruff and remove replaced tools. - Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
387 lines
14 KiB
Python
387 lines
14 KiB
Python
# tests/services/test_session_cleanup.py
|
|
"""
|
|
Comprehensive tests for session cleanup service.
|
|
"""
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.models.user_session import UserSession
|
|
|
|
|
|
class TestCleanupExpiredSessions:
|
|
"""Tests for cleanup_expired_sessions function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_expired_sessions_success(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test successful cleanup of expired sessions."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create mix of sessions
|
|
async with AsyncTestingSessionLocal() as session:
|
|
# 1. Active, not expired (should NOT be deleted)
|
|
active_session = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="active_jti_123",
|
|
device_name="Active Device",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=True,
|
|
expires_at=datetime.now(UTC) + timedelta(days=7),
|
|
created_at=datetime.now(UTC) - timedelta(days=1),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
|
|
# 2. Inactive, expired, old (SHOULD be deleted)
|
|
old_expired_session = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="old_expired_jti",
|
|
device_name="Old Device",
|
|
ip_address="192.168.1.2",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(days=10),
|
|
created_at=datetime.now(UTC) - timedelta(days=40),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
|
|
# 3. Inactive, expired, recent (should NOT be deleted - within keep_days)
|
|
recent_expired_session = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="recent_expired_jti",
|
|
device_name="Recent Device",
|
|
ip_address="192.168.1.3",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(days=1),
|
|
created_at=datetime.now(UTC) - timedelta(days=5),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
|
|
session.add_all(
|
|
[active_session, old_expired_session, recent_expired_session]
|
|
)
|
|
await session.commit()
|
|
|
|
# Mock SessionLocal to return our test session
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
|
|
|
# Should only delete old_expired_session
|
|
assert deleted_count == 1
|
|
|
|
# Verify remaining sessions
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await session.execute(select(UserSession))
|
|
remaining = result.scalars().all()
|
|
assert len(remaining) == 2
|
|
jtis = [s.refresh_token_jti for s in remaining]
|
|
assert "active_jti_123" in jtis
|
|
assert "recent_expired_jti" in jtis
|
|
assert "old_expired_jti" not in jtis
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_no_sessions_to_delete(self, async_test_db, async_test_user):
|
|
"""Test cleanup when no sessions meet deletion criteria."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
active = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="active_only_jti",
|
|
device_name="Active Device",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=True,
|
|
expires_at=datetime.now(UTC) + timedelta(days=7),
|
|
created_at=datetime.now(UTC),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(active)
|
|
await session.commit()
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
|
|
|
assert deleted_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_empty_database(self, async_test_db):
|
|
"""Test cleanup with no sessions in database."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
|
|
|
assert deleted_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_with_keep_days_0(self, async_test_db, async_test_user):
|
|
"""Test cleanup with keep_days=0 deletes all inactive expired sessions."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
today_expired = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="today_expired_jti",
|
|
device_name="Today Expired",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
|
created_at=datetime.now(UTC) - timedelta(hours=2),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(today_expired)
|
|
await session.commit()
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
deleted_count = await cleanup_expired_sessions(keep_days=0)
|
|
|
|
assert deleted_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_bulk_delete_efficiency(self, async_test_db, async_test_user):
|
|
"""Test that cleanup uses bulk DELETE for many sessions."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create 50 expired sessions
|
|
async with AsyncTestingSessionLocal() as session:
|
|
sessions_to_add = []
|
|
for i in range(50):
|
|
expired = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti=f"bulk_jti_{i}",
|
|
device_name=f"Device {i}",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(days=10),
|
|
created_at=datetime.now(UTC) - timedelta(days=40),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
sessions_to_add.append(expired)
|
|
session.add_all(sessions_to_add)
|
|
await session.commit()
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
|
|
|
assert deleted_count == 50
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_database_error_returns_zero(self, async_test_db):
|
|
"""Test cleanup returns 0 on database errors (doesn't crash)."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Mock session_crud.cleanup_expired to raise error
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
with patch(
|
|
"app.services.session_cleanup.session_crud.cleanup_expired"
|
|
) as mock_cleanup:
|
|
mock_cleanup.side_effect = Exception("Database connection lost")
|
|
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
# Should not crash, should return 0
|
|
deleted_count = await cleanup_expired_sessions(keep_days=30)
|
|
|
|
assert deleted_count == 0
|
|
|
|
|
|
class TestGetSessionStatistics:
|
|
"""Tests for get_session_statistics function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_statistics_with_sessions(self, async_test_db, async_test_user):
|
|
"""Test getting session statistics with various session types."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
# 2 active, not expired
|
|
for i in range(2):
|
|
active = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti=f"active_stat_{i}",
|
|
device_name=f"Active {i}",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=True,
|
|
expires_at=datetime.now(UTC) + timedelta(days=7),
|
|
created_at=datetime.now(UTC),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(active)
|
|
|
|
# 3 inactive, expired
|
|
for i in range(3):
|
|
inactive = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti=f"inactive_stat_{i}",
|
|
device_name=f"Inactive {i}",
|
|
ip_address="192.168.1.2",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(days=1),
|
|
created_at=datetime.now(UTC) - timedelta(days=2),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(inactive)
|
|
|
|
# 1 active but expired
|
|
expired_active = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti="expired_active_stat",
|
|
device_name="Expired Active",
|
|
ip_address="192.168.1.3",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=True,
|
|
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
|
created_at=datetime.now(UTC) - timedelta(days=1),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(expired_active)
|
|
|
|
await session.commit()
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import get_session_statistics
|
|
|
|
stats = await get_session_statistics()
|
|
|
|
assert stats["total"] == 6
|
|
assert stats["active"] == 3 # 2 active + 1 expired but active
|
|
assert stats["inactive"] == 3
|
|
assert stats["expired"] == 4 # 3 inactive expired + 1 active expired
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_statistics_empty_database(self, async_test_db):
|
|
"""Test getting statistics with no sessions."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import get_session_statistics
|
|
|
|
stats = await get_session_statistics()
|
|
|
|
assert stats["total"] == 0
|
|
assert stats["active"] == 0
|
|
assert stats["inactive"] == 0
|
|
assert stats["expired"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_statistics_database_error_returns_empty_dict(
|
|
self, async_test_db
|
|
):
|
|
"""Test statistics returns empty dict on database errors."""
|
|
_test_engine, _AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create a mock that raises on execute
|
|
mock_session = AsyncMock()
|
|
mock_session.execute.side_effect = Exception("Database error")
|
|
|
|
@asynccontextmanager
|
|
async def mock_session_local():
|
|
yield mock_session
|
|
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
return_value=mock_session_local(),
|
|
):
|
|
from app.services.session_cleanup import get_session_statistics
|
|
|
|
stats = await get_session_statistics()
|
|
|
|
assert stats == {}
|
|
|
|
|
|
class TestConcurrentCleanup:
|
|
"""Tests for concurrent cleanup scenarios."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_cleanup_no_duplicate_deletes(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test concurrent cleanups don't cause race conditions."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
|
|
# Create 10 expired sessions
|
|
async with AsyncTestingSessionLocal() as session:
|
|
for i in range(10):
|
|
expired = UserSession(
|
|
user_id=async_test_user.id,
|
|
refresh_token_jti=f"concurrent_jti_{i}",
|
|
device_name=f"Device {i}",
|
|
ip_address="192.168.1.1",
|
|
user_agent="Mozilla/5.0",
|
|
is_active=False,
|
|
expires_at=datetime.now(UTC) - timedelta(days=10),
|
|
created_at=datetime.now(UTC) - timedelta(days=40),
|
|
last_used_at=datetime.now(UTC),
|
|
)
|
|
session.add(expired)
|
|
await session.commit()
|
|
|
|
# Run two cleanups concurrently
|
|
# Use side_effect to return fresh session instances for each call
|
|
with patch(
|
|
"app.services.session_cleanup.SessionLocal",
|
|
side_effect=lambda: AsyncTestingSessionLocal(),
|
|
):
|
|
from app.services.session_cleanup import cleanup_expired_sessions
|
|
|
|
results = await asyncio.gather(
|
|
cleanup_expired_sessions(keep_days=30),
|
|
cleanup_expired_sessions(keep_days=30),
|
|
)
|
|
|
|
# Both should report deleting sessions (may overlap due to transaction timing)
|
|
assert sum(results) >= 10
|
|
|
|
# Verify all are deleted
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await session.execute(select(UserSession))
|
|
remaining = result.scalars().all()
|
|
assert len(remaining) == 0
|