- Reformatted headers in E2E tests to improve readability and ensure consistent style. - Updated confidential client fixture to use bcrypt for secret hashing, enhancing security and testing backward compatibility with legacy SHA-256 hashes. - Added new test cases for PKCE verification, rejecting insecure 'plain' methods, and improved error handling. - Refined session workflows and user agent handling in E2E tests for session management. - Consolidated schema operation tests and fixed minor formatting inconsistencies.
332 lines
10 KiB
Python
332 lines
10 KiB
Python
"""
|
|
Session management E2E workflow tests with real PostgreSQL.
|
|
|
|
These tests validate complete session management workflows including:
|
|
- Listing active sessions
|
|
- Session revocation
|
|
- Session cleanup
|
|
- Multi-device session handling
|
|
|
|
Usage:
|
|
make test-e2e # Run all E2E tests
|
|
"""
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
pytestmark = [
|
|
pytest.mark.e2e,
|
|
pytest.mark.postgres,
|
|
pytest.mark.asyncio,
|
|
]
|
|
|
|
|
|
async def register_and_login(
|
|
client,
|
|
email: str,
|
|
password: str = "SecurePassword123!", # noqa: S107
|
|
user_agent: str | None = None,
|
|
):
|
|
"""Helper to register a user and get tokens."""
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"first_name": "Test",
|
|
"last_name": "User",
|
|
},
|
|
)
|
|
|
|
headers = {}
|
|
if user_agent:
|
|
headers["User-Agent"] = user_agent
|
|
|
|
login_resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
headers=headers,
|
|
)
|
|
return login_resp.json()
|
|
|
|
|
|
class TestSessionListingWorkflows:
|
|
"""Test session listing workflows."""
|
|
|
|
async def test_list_sessions_after_login(self, e2e_client):
|
|
"""Users can list their active sessions after login."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
response = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "sessions" in data
|
|
assert "total" in data
|
|
assert data["total"] >= 1
|
|
assert len(data["sessions"]) >= 1
|
|
|
|
async def test_session_contains_expected_fields(self, e2e_client):
|
|
"""Session response contains expected fields."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
response = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
data = response.json()
|
|
session = data["sessions"][0]
|
|
|
|
# Check required fields
|
|
assert "id" in session
|
|
assert "created_at" in session
|
|
assert "last_used_at" in session
|
|
assert "is_current" in session
|
|
|
|
async def test_list_sessions_requires_auth(self, e2e_client):
|
|
"""Sessions endpoint requires authentication."""
|
|
response = await e2e_client.get("/api/v1/sessions/me")
|
|
assert response.status_code == 401
|
|
|
|
async def test_multiple_logins_create_multiple_sessions(self, e2e_client):
|
|
"""Multiple logins create multiple sessions."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
password = "SecurePassword123!"
|
|
|
|
# Register
|
|
await e2e_client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"first_name": "Test",
|
|
"last_name": "User",
|
|
},
|
|
)
|
|
|
|
# Login multiple times with different user agents
|
|
tokens1 = (
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
|
|
)
|
|
).json()
|
|
|
|
# Second login to create another session
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
headers={"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0)"},
|
|
)
|
|
|
|
# Check sessions using first token
|
|
response = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
|
|
data = response.json()
|
|
assert data["total"] >= 2
|
|
|
|
|
|
class TestSessionRevocationWorkflows:
|
|
"""Test session revocation workflows."""
|
|
|
|
async def test_revoke_own_session(self, e2e_client):
|
|
"""Users can revoke their own sessions."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
password = "SecurePassword123!"
|
|
|
|
# Register
|
|
await e2e_client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"first_name": "Test",
|
|
"last_name": "User",
|
|
},
|
|
)
|
|
|
|
# Create two sessions
|
|
tokens1 = (
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
).json()
|
|
|
|
# Second login to create another session
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
|
|
# Get sessions
|
|
sessions_resp = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
sessions = sessions_resp.json()["sessions"]
|
|
initial_count = len(sessions)
|
|
|
|
# Revoke one session (not the current one)
|
|
session_to_revoke = sessions[-1]["id"]
|
|
revoke_resp = await e2e_client.delete(
|
|
f"/api/v1/sessions/{session_to_revoke}",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
|
|
assert revoke_resp.status_code == 200
|
|
assert revoke_resp.json()["success"] is True
|
|
|
|
# Verify session count decreased
|
|
updated_sessions_resp = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
updated_count = updated_sessions_resp.json()["total"]
|
|
assert updated_count == initial_count - 1
|
|
|
|
async def test_cannot_revoke_nonexistent_session(self, e2e_client):
|
|
"""Cannot revoke a session that doesn't exist."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
fake_session_id = str(uuid4())
|
|
response = await e2e_client.delete(
|
|
f"/api/v1/sessions/{fake_session_id}",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
async def test_cannot_revoke_other_user_session(self, e2e_client):
|
|
"""Users cannot revoke other users' sessions."""
|
|
user1_email = f"e2e-user1-{uuid4().hex[:8]}@example.com"
|
|
user2_email = f"e2e-user2-{uuid4().hex[:8]}@example.com"
|
|
|
|
tokens1 = await register_and_login(e2e_client, user1_email)
|
|
tokens2 = await register_and_login(e2e_client, user2_email)
|
|
|
|
# Get user1's session ID
|
|
sessions_resp = await e2e_client.get(
|
|
"/api/v1/sessions/me",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
user1_session_id = sessions_resp.json()["sessions"][0]["id"]
|
|
|
|
# User2 tries to revoke user1's session
|
|
response = await e2e_client.delete(
|
|
f"/api/v1/sessions/{user1_session_id}",
|
|
headers={"Authorization": f"Bearer {tokens2['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
class TestSessionCleanupWorkflows:
|
|
"""Test session cleanup workflows."""
|
|
|
|
async def test_cleanup_expired_sessions(self, e2e_client):
|
|
"""Users can cleanup their expired sessions."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
response = await e2e_client.delete(
|
|
"/api/v1/sessions/me/expired",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert "Cleaned up" in data["message"]
|
|
|
|
async def test_cleanup_requires_auth(self, e2e_client):
|
|
"""Session cleanup requires authentication."""
|
|
response = await e2e_client.delete("/api/v1/sessions/me/expired")
|
|
assert response.status_code == 401
|
|
|
|
|
|
class TestLogoutWorkflows:
|
|
"""Test logout workflows."""
|
|
|
|
async def test_logout_invalidates_session(self, e2e_client):
|
|
"""Logout should invalidate the session."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
tokens = await register_and_login(e2e_client, email)
|
|
|
|
# Logout
|
|
logout_resp = await e2e_client.post(
|
|
"/api/v1/auth/logout",
|
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
json={"refresh_token": tokens["refresh_token"]},
|
|
)
|
|
|
|
assert logout_resp.status_code == 200
|
|
|
|
# Refresh token should no longer work
|
|
refresh_resp = await e2e_client.post(
|
|
"/api/v1/auth/refresh",
|
|
json={"refresh_token": tokens["refresh_token"]},
|
|
)
|
|
|
|
# May be 401 or 400 depending on implementation
|
|
assert refresh_resp.status_code in [400, 401]
|
|
|
|
async def test_logout_all_revokes_all_sessions(self, e2e_client):
|
|
"""Logout all should revoke all sessions."""
|
|
email = f"e2e-{uuid4().hex[:8]}@example.com"
|
|
password = "SecurePassword123!"
|
|
|
|
# Register
|
|
await e2e_client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": email,
|
|
"password": password,
|
|
"first_name": "Test",
|
|
"last_name": "User",
|
|
},
|
|
)
|
|
|
|
# Create multiple sessions
|
|
tokens1 = (
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
).json()
|
|
|
|
tokens2 = (
|
|
await e2e_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password},
|
|
)
|
|
).json()
|
|
|
|
# Logout all
|
|
logout_resp = await e2e_client.post(
|
|
"/api/v1/auth/logout-all",
|
|
headers={"Authorization": f"Bearer {tokens1['access_token']}"},
|
|
)
|
|
|
|
assert logout_resp.status_code == 200
|
|
|
|
# Second token's refresh should no longer work
|
|
refresh_resp = await e2e_client.post(
|
|
"/api/v1/auth/refresh",
|
|
json={"refresh_token": tokens2["refresh_token"]},
|
|
)
|
|
|
|
assert refresh_resp.status_code in [400, 401]
|