32351a2134
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/0683a057-d74b-4b1a-aeab-47f5bfbe4190 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
508 lines
21 KiB
Python
508 lines
21 KiB
Python
"""
|
||
Tests for the Logto-based authentication layer.
|
||
|
||
These tests exercise:
|
||
- Session-token creation and decoding (app.core.logto)
|
||
- CookieStorage read/write/delete semantics
|
||
- sync_logto_user DB upsert logic
|
||
- /api/v1/auth/me – authenticated and unauthenticated
|
||
- /api/v1/auth/sign-in – Logto not configured → 503
|
||
- /api/v1/auth/sign-out – always clears the session cookie
|
||
- SSL bypass patching (_apply_logto_ssl_patch)
|
||
|
||
All tests use the in-memory SQLite fixture from conftest.py.
|
||
Logto SDK calls are mocked so no live Logto instance is needed.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.core.logto import (
|
||
SESSION_COOKIE,
|
||
CookieStorage,
|
||
create_session_token,
|
||
decode_session_token,
|
||
sync_logto_user,
|
||
)
|
||
from app.models.user import User
|
||
|
||
# ── Session token helpers ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSessionToken:
|
||
def test_roundtrip(self):
|
||
token = create_session_token(user_id=7)
|
||
assert decode_session_token(token) == 7
|
||
|
||
def test_invalid_token_returns_none(self):
|
||
assert decode_session_token("not.a.token") is None
|
||
|
||
def test_wrong_type_returns_none(self):
|
||
"""A generic JWT without the dmarq_session type claim should be rejected."""
|
||
from jose import jwt
|
||
|
||
from app.core.config import get_settings
|
||
|
||
s = get_settings()
|
||
payload = {"sub": "5", "type": "other"}
|
||
bad_token = jwt.encode(payload, s.SECRET_KEY, algorithm=s.ALGORITHM)
|
||
assert decode_session_token(bad_token) is None
|
||
|
||
|
||
# ── CookieStorage ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCookieStorage:
|
||
def _make_request(self, cookies: dict = None):
|
||
req = MagicMock()
|
||
req.cookies = cookies or {}
|
||
return req
|
||
|
||
def _make_response(self):
|
||
from starlette.responses import Response
|
||
|
||
return Response()
|
||
|
||
def test_get_from_request_cookies(self):
|
||
req = self._make_request({"logto_idToken": "abc123"})
|
||
storage = CookieStorage(req)
|
||
assert storage.get("idToken") == "abc123"
|
||
|
||
def test_pending_write_shadows_cookie(self):
|
||
req = self._make_request({"logto_idToken": "old"})
|
||
storage = CookieStorage(req)
|
||
storage.set("idToken", "new")
|
||
assert storage.get("idToken") == "new"
|
||
|
||
def test_delete_shadows_cookie(self):
|
||
req = self._make_request({"logto_idToken": "exists"})
|
||
storage = CookieStorage(req)
|
||
storage.delete("idToken")
|
||
assert storage.get("idToken") is None
|
||
|
||
def test_apply_to_response_sets_cookies(self):
|
||
storage = CookieStorage(self._make_request())
|
||
storage.set("idToken", "tok123")
|
||
resp = self._make_response()
|
||
storage.apply_to_response(resp)
|
||
# Cookie header should contain the key
|
||
header_str = str(resp.headers.get("set-cookie", ""))
|
||
assert "logto_idToken" in header_str
|
||
|
||
def test_apply_to_response_deletes_cookies(self):
|
||
req = self._make_request({"logto_idToken": "old"})
|
||
storage = CookieStorage(req)
|
||
storage.delete("idToken")
|
||
resp = self._make_response()
|
||
storage.apply_to_response(resp)
|
||
header_str = str(resp.headers.get("set-cookie", ""))
|
||
assert "logto_idToken" in header_str
|
||
# A deleted cookie is set with max-age=0
|
||
assert "Max-Age=0" in header_str or "expires" in header_str.lower()
|
||
|
||
|
||
# ── sync_logto_user ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSyncLogtoUser:
|
||
def _claims(self, sub="logto-sub-1", email="user@example.com", name="Test User"):
|
||
claims = MagicMock()
|
||
claims.sub = sub
|
||
claims.email = email
|
||
claims.name = name
|
||
claims.username = None
|
||
claims.picture = None
|
||
claims.email_verified = True
|
||
return claims
|
||
|
||
def test_creates_new_user(self, db_session):
|
||
claims = self._claims()
|
||
user = sync_logto_user(claims, db_session)
|
||
assert user.id is not None
|
||
assert user.logto_id == "logto-sub-1"
|
||
assert user.email == "user@example.com"
|
||
assert user.full_name == "Test User"
|
||
assert user.is_superuser is True
|
||
|
||
def test_returns_existing_user_by_logto_id(self, db_session):
|
||
# Create user first
|
||
claims = self._claims()
|
||
user1 = sync_logto_user(claims, db_session)
|
||
uid = user1.id
|
||
|
||
# Second call with same sub → same user, no duplicate
|
||
user2 = sync_logto_user(claims, db_session)
|
||
assert user2.id == uid
|
||
total = db_session.query(User).count()
|
||
assert total == 1
|
||
|
||
def test_links_existing_user_by_email(self, db_session):
|
||
"""Legacy user with matching email but no logto_id gets linked."""
|
||
legacy = User(email="user@example.com", is_active=True, is_superuser=True)
|
||
db_session.add(legacy)
|
||
db_session.commit()
|
||
|
||
claims = self._claims(sub="new-sub", email="user@example.com")
|
||
user = sync_logto_user(claims, db_session)
|
||
|
||
assert user.id == legacy.id
|
||
assert user.logto_id == "new-sub"
|
||
|
||
def test_updates_profile_on_subsequent_login(self, db_session):
|
||
claims = self._claims(name="Old Name")
|
||
sync_logto_user(claims, db_session)
|
||
|
||
claims2 = self._claims(name="New Name")
|
||
user = sync_logto_user(claims2, db_session)
|
||
assert user.full_name == "New Name"
|
||
|
||
|
||
# ── /api/v1/auth/me ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAuthMeEndpoint:
|
||
def test_me_unauthenticated_returns_401(self, client: TestClient):
|
||
res = client.get("/api/v1/auth/me")
|
||
assert res.status_code == 401
|
||
|
||
def test_me_with_valid_session_returns_user(self, client: TestClient, db_session):
|
||
# Create a user in the DB
|
||
user = User(
|
||
email="me@example.com",
|
||
logto_id="sub-me",
|
||
is_active=True,
|
||
is_superuser=True,
|
||
)
|
||
db_session.add(user)
|
||
db_session.commit()
|
||
db_session.refresh(user)
|
||
|
||
token = create_session_token(user.id)
|
||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: token})
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert data["email"] == "me@example.com"
|
||
assert data["logto_id"] == "sub-me"
|
||
|
||
def test_me_with_invalid_session_returns_401(self, client: TestClient):
|
||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: "garbage"})
|
||
assert res.status_code == 401
|
||
|
||
def test_me_with_inactive_user_returns_401(self, client: TestClient, db_session):
|
||
user = User(
|
||
email="inactive@example.com",
|
||
logto_id="sub-inactive",
|
||
is_active=False,
|
||
is_superuser=True,
|
||
)
|
||
db_session.add(user)
|
||
db_session.commit()
|
||
db_session.refresh(user)
|
||
|
||
token = create_session_token(user.id)
|
||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: token})
|
||
assert res.status_code == 401
|
||
|
||
|
||
# ── /api/v1/auth/callback ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCallbackEndpoint:
|
||
"""Tests for the Logto OIDC authorization-code callback handler."""
|
||
|
||
def _make_mock_claims(self):
|
||
claims = MagicMock()
|
||
claims.sub = "logto-sub-callback"
|
||
claims.email = "callback@example.com"
|
||
claims.name = "Callback User"
|
||
claims.username = None
|
||
claims.picture = None
|
||
claims.email_verified = True
|
||
return claims
|
||
|
||
def _mock_client(self, handle_error=None, claims_error=None, claims=None):
|
||
"""Build a mock LogtoClient with configurable side effects."""
|
||
mock_client = MagicMock()
|
||
if handle_error:
|
||
mock_client.handleSignInCallback = AsyncMock(side_effect=handle_error)
|
||
else:
|
||
mock_client.handleSignInCallback = AsyncMock()
|
||
if claims_error:
|
||
mock_client.getIdTokenClaims.side_effect = claims_error
|
||
elif claims is not None:
|
||
mock_client.getIdTokenClaims.return_value = claims
|
||
return mock_client
|
||
|
||
def test_callback_without_logto_config_returns_503(self, client: TestClient):
|
||
"""When Logto is not configured the callback must return 503."""
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = False
|
||
res = client.get("/api/v1/auth/callback", follow_redirects=False)
|
||
assert res.status_code == 503
|
||
|
||
def test_callback_handle_signin_error_redirects_to_callback_failed(self, client: TestClient):
|
||
"""If handleSignInCallback raises, redirect to /login?error=callback_failed."""
|
||
mock_client = self._mock_client(handle_error=Exception("bad state"))
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = True
|
||
with patch("app.api.api_v1.endpoints.auth.make_logto_client", return_value=mock_client):
|
||
res = client.get("/api/v1/auth/callback?code=bad", follow_redirects=False)
|
||
assert res.status_code == 302
|
||
assert "callback_failed" in res.headers["location"]
|
||
|
||
def test_callback_get_claims_error_redirects_to_token_error(self, client: TestClient):
|
||
"""If getIdTokenClaims raises, redirect to /login?error=token_error."""
|
||
mock_client = self._mock_client(claims_error=Exception("claims unavailable"))
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = True
|
||
with patch("app.api.api_v1.endpoints.auth.make_logto_client", return_value=mock_client):
|
||
res = client.get("/api/v1/auth/callback?code=x", follow_redirects=False)
|
||
assert res.status_code == 302
|
||
assert "token_error" in res.headers["location"]
|
||
|
||
def test_callback_success_issues_session_cookie_and_redirects_to_root(self, client: TestClient):
|
||
"""Successful callback must issue the dmarq_session cookie and redirect to /."""
|
||
claims = self._make_mock_claims()
|
||
mock_client = self._mock_client(claims=claims)
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = True
|
||
with patch("app.api.api_v1.endpoints.auth.make_logto_client", return_value=mock_client):
|
||
res = client.get("/api/v1/auth/callback?code=good", follow_redirects=False)
|
||
assert res.status_code == 302
|
||
assert res.headers["location"] == "/"
|
||
set_cookie = res.headers.get("set-cookie", "")
|
||
assert SESSION_COOKIE in set_cookie
|
||
|
||
def test_callback_success_respects_logto_next_cookie(self, client: TestClient):
|
||
"""After a successful callback the user is redirected to the stored next URL."""
|
||
claims = self._make_mock_claims()
|
||
mock_client = self._mock_client(claims=claims)
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = True
|
||
with patch("app.api.api_v1.endpoints.auth.make_logto_client", return_value=mock_client):
|
||
res = client.get(
|
||
"/api/v1/auth/callback?code=good",
|
||
cookies={"logto_next": "/dashboard"},
|
||
follow_redirects=False,
|
||
)
|
||
assert res.status_code == 302
|
||
assert res.headers["location"] == "/dashboard"
|
||
|
||
|
||
# ── /api/v1/auth/sign-in ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSignInEndpoint:
|
||
def test_sign_in_without_logto_config_returns_503(self, client: TestClient):
|
||
"""When Logto is not configured the endpoint must return 503."""
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.logto_configured = False
|
||
res = client.get("/api/v1/auth/sign-in", follow_redirects=False)
|
||
assert res.status_code == 503
|
||
|
||
|
||
# ── /api/v1/auth/sign-out ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSignOutEndpoint:
|
||
def test_sign_out_clears_session_cookie(self, client: TestClient):
|
||
"""Sign-out must delete the dmarq_session cookie regardless of Logto config."""
|
||
token = create_session_token(user_id=1)
|
||
# Use allow_redirects=False so we see the redirect response with cookies
|
||
res = client.get(
|
||
"/api/v1/auth/sign-out",
|
||
cookies={SESSION_COOKIE: token},
|
||
follow_redirects=False,
|
||
)
|
||
# Should redirect (to /login or Logto end_session)
|
||
assert res.status_code in (302, 307)
|
||
# The session cookie must be cleared (max-age=0 or expires in past)
|
||
set_cookie = res.headers.get("set-cookie", "")
|
||
assert SESSION_COOKIE in set_cookie
|
||
assert "Max-Age=0" in set_cookie or "max-age=0" in set_cookie
|
||
|
||
|
||
# ── AUTH_DISABLED mode ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAuthDisabled:
|
||
"""Verify the AUTH_DISABLED=true no-auth fallback mode."""
|
||
|
||
def test_me_returns_synthetic_admin_when_auth_disabled(self, client: TestClient):
|
||
"""With AUTH_DISABLED, /me must return the synthetic admin profile."""
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.AUTH_DISABLED = True
|
||
res = client.get("/api/v1/auth/me")
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert data["is_superuser"] is True
|
||
assert data["auth_disabled"] is True
|
||
assert data["email"] == "admin@localhost"
|
||
|
||
def test_sign_out_redirects_to_root_when_auth_disabled(self, client: TestClient):
|
||
"""With AUTH_DISABLED, sign-out should redirect to / (no Logto session to clear)."""
|
||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||
mock_settings.AUTH_DISABLED = True
|
||
res = client.get("/api/v1/auth/sign-out", follow_redirects=False)
|
||
assert res.status_code == 302
|
||
assert res.headers["location"] == "/"
|
||
|
||
def test_require_admin_auth_passes_when_disabled(self):
|
||
"""require_admin_auth must return a synthetic context when AUTH_DISABLED=True."""
|
||
import asyncio
|
||
from unittest.mock import MagicMock
|
||
|
||
from app.core.security import require_admin_auth
|
||
|
||
with patch("app.core.security.settings") as mock_settings:
|
||
mock_settings.AUTH_DISABLED = True
|
||
mock_req = MagicMock()
|
||
mock_req.cookies = {}
|
||
result = asyncio.get_event_loop().run_until_complete(
|
||
require_admin_auth(request=mock_req, api_key=None, bearer=None)
|
||
)
|
||
assert result["auth_type"] == "disabled"
|
||
|
||
def test_middleware_passes_all_requests_when_auth_disabled(self, client: TestClient):
|
||
"""The auth middleware must let every request through when AUTH_DISABLED=True."""
|
||
# The middleware does `from app.core.config import get_settings` inside dispatch,
|
||
# so we patch the canonical location used at call time.
|
||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||
mock_cfg = MagicMock()
|
||
mock_cfg.AUTH_DISABLED = True
|
||
mock_get_settings.return_value = mock_cfg
|
||
# Even without a session cookie, the middleware lets the request through.
|
||
# The endpoint itself then handles auth (API key or 401), but it must
|
||
# never be a 302 redirect from the middleware.
|
||
res = client.get("/settings", follow_redirects=False)
|
||
assert res.status_code != 302
|
||
|
||
|
||
# ── Static asset bypass ───────────────────────────────────────────────────────
|
||
|
||
|
||
class TestStaticAssetBypass:
|
||
"""Static assets must never be redirected to the login page."""
|
||
|
||
@staticmethod
|
||
def _logto_configured_mock():
|
||
mock_cfg = MagicMock()
|
||
mock_cfg.AUTH_DISABLED = False
|
||
mock_cfg.logto_configured = True
|
||
return mock_cfg
|
||
|
||
def test_favicon_not_redirected_to_login(self, client: TestClient):
|
||
"""GET /favicon.ico without a session must pass through (not redirect to /login)."""
|
||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||
mock_get_settings.return_value = self._logto_configured_mock()
|
||
res = client.get("/favicon.ico", follow_redirects=False)
|
||
assert res.status_code != 302
|
||
|
||
def test_png_asset_not_redirected_to_login(self, client: TestClient):
|
||
"""GET /logo.png without a session must pass through."""
|
||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||
mock_get_settings.return_value = self._logto_configured_mock()
|
||
res = client.get("/logo.png", follow_redirects=False)
|
||
assert res.status_code != 302
|
||
|
||
def test_protected_page_still_redirected(self, client: TestClient):
|
||
"""GET /dashboard without a session must still redirect to /login."""
|
||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||
mock_get_settings.return_value = self._logto_configured_mock()
|
||
res = client.get("/dashboard", follow_redirects=False)
|
||
assert res.status_code == 302
|
||
assert res.headers["location"].startswith("/login")
|
||
|
||
|
||
# ── SSL bypass patch ──────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestApplyLogtoSslPatch:
|
||
"""_apply_logto_ssl_patch should extend both the aiohttp and PyJWKClient patches."""
|
||
|
||
def test_no_patch_when_ssl_verify_enabled(self):
|
||
"""When LOGTO_SKIP_SSL_VERIFY is False the function must not modify aiohttp."""
|
||
import aiohttp
|
||
|
||
original = aiohttp.ClientSession
|
||
|
||
mock_settings = MagicMock()
|
||
mock_settings.LOGTO_SKIP_SSL_VERIFY = False
|
||
|
||
with patch("app.core.logto.settings", mock_settings):
|
||
from app.core.logto import _apply_logto_ssl_patch
|
||
|
||
_apply_logto_ssl_patch()
|
||
|
||
assert aiohttp.ClientSession is original
|
||
|
||
def test_aiohttp_patched_when_ssl_skip_enabled(self):
|
||
"""When LOGTO_SKIP_SSL_VERIFY is True the aiohttp.ClientSession must be replaced."""
|
||
import aiohttp
|
||
|
||
original = aiohttp.ClientSession
|
||
|
||
mock_settings = MagicMock()
|
||
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
|
||
|
||
with patch("app.core.logto.settings", mock_settings):
|
||
from app.core.logto import _apply_logto_ssl_patch
|
||
|
||
_apply_logto_ssl_patch()
|
||
|
||
try:
|
||
assert aiohttp.ClientSession is not original
|
||
finally:
|
||
# Restore so later tests are not affected.
|
||
aiohttp.ClientSession = original
|
||
|
||
def test_pyjwkclient_patched_when_ssl_skip_enabled(self):
|
||
"""When LOGTO_SKIP_SSL_VERIFY is True, PyJWKClient in logto.OidcCore must be
|
||
replaced with a subclass that injects a non-verifying ssl_context."""
|
||
import logto.OidcCore as _oidc_module
|
||
from jwt import PyJWKClient
|
||
|
||
original_pyjwkclient = _oidc_module.PyJWKClient
|
||
|
||
mock_settings = MagicMock()
|
||
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
|
||
|
||
with patch("app.core.logto.settings", mock_settings):
|
||
from app.core.logto import _apply_logto_ssl_patch
|
||
|
||
_apply_logto_ssl_patch()
|
||
|
||
try:
|
||
patched = _oidc_module.PyJWKClient
|
||
assert patched is not PyJWKClient, "PyJWKClient should be replaced"
|
||
assert issubclass(patched, PyJWKClient), "Replacement must subclass PyJWKClient"
|
||
finally:
|
||
_oidc_module.PyJWKClient = original_pyjwkclient
|
||
|
||
def test_pyjwkclient_patch_injects_ssl_context(self):
|
||
"""The patched PyJWKClient must pass ssl_context to its parent when constructed."""
|
||
import ssl
|
||
|
||
import logto.OidcCore as _oidc_module
|
||
|
||
original_pyjwkclient = _oidc_module.PyJWKClient
|
||
|
||
mock_settings = MagicMock()
|
||
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
|
||
|
||
with patch("app.core.logto.settings", mock_settings):
|
||
from app.core.logto import _apply_logto_ssl_patch
|
||
|
||
_apply_logto_ssl_patch()
|
||
|
||
try:
|
||
instance = _oidc_module.PyJWKClient("https://example.com/.well-known/jwks.json")
|
||
assert instance.ssl_context is not None
|
||
assert isinstance(instance.ssl_context, ssl.SSLContext)
|
||
assert instance.ssl_context.verify_mode == ssl.CERT_NONE
|
||
finally:
|
||
_oidc_module.PyJWKClient = original_pyjwkclient
|