Files
gh-christianlouis-docuelevate/tests/test_api_sessions.py
T

440 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the session management API endpoints (app/api/sessions.py).
Covers:
* _get_owner_id dependency helper (authenticated and unauthenticated paths)
* GET /api/sessions/ list sessions
* DELETE /api/sessions/{id} revoke a single session
* POST /api/sessions/revoke-all log off everywhere
"""
from __future__ import annotations
import base64
import json
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import itsdangerous
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import UserSession
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_OWNER = "sessionuser@example.com"
_OTHER_OWNER = "other@example.com"
_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session_cookie(session_data: dict) -> str:
"""Encode *session_data* as a signed Starlette session cookie value."""
signer = itsdangerous.TimestampSigner(_SESSION_SECRET)
data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
return signer.sign(data).decode("utf-8")
def _make_user_session(
db,
user_id: str = _OWNER,
session_token: str | None = None,
expires_delta: timedelta = timedelta(days=30),
) -> UserSession:
"""Create and persist a UserSession in *db*."""
now = datetime.now(timezone.utc)
token = session_token or secrets.token_urlsafe(32)
session = UserSession(
session_token=token,
user_id=user_id,
ip_address="127.0.0.1",
user_agent="TestBrowser/1.0",
device_info="TestBrowser on Linux",
created_at=now,
last_active_at=now,
expires_at=now + expires_delta,
)
db.add(session)
db.commit()
db.refresh(session)
return session
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sess_engine():
"""In-memory SQLite engine scoped to one test."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def sess_db(sess_engine):
"""Database session scoped to one test."""
Session = sessionmaker(bind=sess_engine)
session = Session()
yield session
session.close()
def _make_client(sess_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.sessions import _get_owner_id
from app.main import app
Session = sessionmaker(bind=sess_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _make_unauthenticated_client(sess_engine) -> TestClient:
"""Return a TestClient with only the DB overridden (no auth injection)."""
from app.main import app
Session = sessionmaker(bind=sess_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_get_db
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _cleanup():
"""Remove all dependency overrides from the app."""
from app.main import app
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests _get_owner_id helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper in app/api/sessions.py."""
@pytest.mark.unit
def test_unauthenticated_raises_401(self):
"""_get_owner_id should raise HTTP 401 when the user is not authenticated."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.get_current_owner_id", return_value=None):
with pytest.raises(HTTPException) as exc_info:
_get_owner_id(mock_request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Not authenticated"
@pytest.mark.unit
def test_authenticated_returns_owner_id(self):
"""_get_owner_id should return the owner_id when the user is authenticated."""
from unittest.mock import MagicMock
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.get_current_owner_id", return_value=_OWNER):
result = _get_owner_id(mock_request)
assert result == _OWNER
# ---------------------------------------------------------------------------
# Tests GET /api/sessions/
# ---------------------------------------------------------------------------
class TestListSessions:
"""Tests for GET /api/sessions/."""
@pytest.mark.unit
def test_list_sessions_empty(self, sess_engine):
"""Returns an empty session list when no sessions exist."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
data = resp.json()
assert data["sessions"] == []
assert "session_lifetime_days" in data
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_active_sessions(self, sess_engine, sess_db):
"""Returns session details for all active sessions belonging to the user."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
# A session owned by a different user must not appear.
_make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
sessions = resp.json()["sessions"]
assert len(sessions) == 2
for s in sessions:
assert "id" in s
assert "device_info" in s
assert "ip_address" in s
assert "created_at" in s
assert "last_active_at" in s
assert "expires_at" in s
assert "is_current" in s
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_marks_current_session(self, sess_engine, sess_db):
"""The session whose token matches request.session['_session_token'] is
marked ``is_current=True``; all others are ``False``."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
other_session = _make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/", cookies={"session": cookie})
assert resp.status_code == 200
sessions = resp.json()["sessions"]
session_map = {s["id"]: s for s in sessions}
assert session_map[current_session.id]["is_current"] is True
assert session_map[other_session.id]["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_no_current_token(self, sess_engine, sess_db):
"""When no _session_token is present, all sessions have is_current=False."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
for s in resp.json()["sessions"]:
assert s["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_lifetime_days(self, sess_engine):
"""Response always includes session_lifetime_days."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
assert isinstance(resp.json()["session_lifetime_days"], int)
assert resp.json()["session_lifetime_days"] >= 1
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests DELETE /api/sessions/{session_id}
# ---------------------------------------------------------------------------
class TestRevokeSingleSession:
"""Tests for DELETE /api/sessions/{session_id}."""
@pytest.mark.unit
def test_revoke_session_success(self, sess_engine, sess_db):
"""Revoking an owned session returns 204 No Content."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_not_found(self, sess_engine):
"""Revoking a non-existent session returns 404."""
client = _make_client(sess_engine)
try:
resp = client.delete("/api/sessions/999999")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_belonging_to_other_user_returns_404(self, sess_engine, sess_db):
"""A user cannot revoke another user's session (returns 404)."""
other_session = _make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine, owner_id=_OWNER)
try:
resp = client.delete(f"/api/sessions/{other_session.id}")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises an exception, the response is still 204."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests POST /api/sessions/revoke-all
# ---------------------------------------------------------------------------
class TestRevokeAllSessions:
"""Tests for POST /api/sessions/revoke-all."""
@pytest.mark.unit
def test_revoke_all_no_sessions(self, sess_engine):
"""Returns revoked_count=0 when there are no sessions to revoke."""
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 0
assert "message" in data
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_revokes_all_sessions(self, sess_engine, sess_db):
"""All active sessions for the user are revoked."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 2
assert "2" in data["message"]
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_preserves_current_session(self, sess_engine, sess_db):
"""The session matching the current _session_token is NOT revoked."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
# Only the two non-current sessions should be revoked.
assert resp.json()["revoked_count"] == 2
# The current session must still be active in the DB.
sess_db.refresh(current_session)
assert current_session.is_revoked is False
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_current_session_token_not_in_db(self, sess_engine, sess_db):
"""When the _session_token in the cookie doesn't match any DB row,
all sessions are revoked (no session is preserved)."""
_make_user_session(sess_db, user_id=_OWNER)
# Cookie references a token that does not exist in the DB.
cookie = _make_session_cookie({"_session_token": "ghost_token_xyz"})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises, revoke-all still returns 200."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_message_format(self, sess_engine, sess_db):
"""Response message includes the count and mentions API tokens."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
msg = resp.json()["message"]
assert "1" in msg
assert "API" in msg or "token" in msg.lower()
finally:
_cleanup()