c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
440 lines
15 KiB
Python
440 lines
15 KiB
Python
"""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()
|