Files
gh-christianlouis-dmarq/backend/app/tests/test_auth.py
T
copilot-swe-agent[bot] 531dc968a8 feat: integrate Logto OIDC for user authentication
- Add Logto OIDC integration (app/core/logto.py): CookieStorage adapter,
  create/decode session token helpers, sync_logto_user upsert
- New auth endpoints (/api/v1/auth): sign-in, callback, sign-out, me
- AuthRedirectMiddleware: protects HTML pages, redirects to /setup when
  Logto is unconfigured, to /login otherwise
- Update require_admin_auth: accepts dmarq_session cookie JWT first,
  then API key, then Bearer JWT (fully backward compatible)
- Update User model: add logto_id, username, picture, created_at, updated_at;
  make hashed_password nullable for Logto-only users; is_superuser default=True
- New Alembic migration d4e5f6a7b8c9 for the above schema changes
- Add LOGTO_ENDPOINT / LOGTO_APP_ID / LOGTO_APP_SECRET / LOGTO_REDIRECT_URI
  settings with logto_configured property
- Create login.html (Sign in with Logto button) and setup.html (step-by-step
  configuration guide)
- Update base.html: user menu with avatar/name and sign-out via Alpine.js
  fetch to /api/v1/auth/me
- Update settings.html: remove localStorage adminApiKey; session cookie is
  sent automatically by browser; add 401 → /login redirect
- Update requirements.txt: replace fastapi-users additions with logto + aiohttp
- Add test_auth.py: 18 new tests covering session tokens, CookieStorage,
  sync_logto_user, /me, /sign-in (503), /sign-out cookie clearing
- Fix test_security_extra.py: pass Request mock to require_admin_auth;
  add new test_valid_session_cookie_returns_auth_context

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/b448f585-7646-40f8-ae2d-9986c361e3fd

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-30 10:09:50 +00:00

240 lines
8.9 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
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 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/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