Files
gh-christianlouis-dmarq/backend/app/tests/test_auth.py
T

508 lines
21 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 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