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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 10:09:50 +00:00
parent 308e6f8d91
commit 531dc968a8
21 changed files with 1496 additions and 203 deletions
+239
View File
@@ -0,0 +1,239 @@
"""
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
+33 -41
View File
@@ -9,8 +9,8 @@ import base64
import email as email_mod
import json
from email import encoders as email_encoders
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from unittest.mock import MagicMock, patch
@@ -19,7 +19,6 @@ import pytest
from app.services.gmail_client import GmailClient
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -234,9 +233,7 @@ class TestGetGmailEmail:
assert result is None
def test_returns_none_on_exception(self):
with patch(
"app.services.gmail_client.httpx.get", side_effect=Exception("network error")
):
with patch("app.services.gmail_client.httpx.get", side_effect=Exception("network error")):
result = GmailClient.get_gmail_email("some-token")
assert result is None
@@ -264,8 +261,9 @@ class TestBuildService:
client._mock_creds.refresh_token = "ref"
mock_service = MagicMock()
with patch("app.services.gmail_client.build", return_value=mock_service), patch(
"app.services.gmail_client.Request"
with (
patch("app.services.gmail_client.build", return_value=mock_service),
patch("app.services.gmail_client.Request"),
):
svc = client._build_service()
@@ -278,9 +276,7 @@ class TestBuildService:
client._mock_creds.refresh_token = "ref"
client._mock_creds.refresh.side_effect = Exception("refresh failed")
with patch("app.services.gmail_client.Request"), patch(
"app.services.gmail_client.build"
):
with patch("app.services.gmail_client.Request"), patch("app.services.gmail_client.build"):
with pytest.raises(Exception, match="refresh failed"):
client._build_service()
@@ -397,9 +393,7 @@ class TestProcessMessage:
def test_fetches_and_processes_message(self):
"""Happy path: message fetched, attachments processed."""
client = _make_client()
raw_email = _make_raw_email(
[{"filename": "report.xml", "content": b"<xml/>"}]
)
raw_email = _make_raw_email([{"filename": "report.xml", "content": b"<xml/>"}])
raw_b64 = _b64_raw(raw_email)
service = MagicMock()
@@ -440,9 +434,7 @@ class TestProcessMessage:
class TestProcessAttachments:
def test_no_attachments_returns_zero(self):
client = _make_client()
msg = email_mod.message_from_bytes(
b"From: a@b.com\r\nTo: c@d.com\r\n\r\nHello"
)
msg = email_mod.message_from_bytes(b"From: a@b.com\r\nTo: c@d.com\r\n\r\nHello")
stats = {"reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
assert count == 0
@@ -450,9 +442,7 @@ class TestProcessAttachments:
def test_non_dmarc_attachment_skipped(self):
"""An inline or non-DMARC file should not count as a report."""
client = _make_client()
raw = _make_raw_email(
[{"filename": "photo.png", "content": b"\x89PNG"}]
)
raw = _make_raw_email([{"filename": "photo.png", "content": b"\x89PNG"}])
msg = email_mod.message_from_bytes(raw)
stats = {"reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
@@ -462,9 +452,7 @@ class TestProcessAttachments:
def test_dmarc_xml_attachment_is_parsed(self):
"""A .xml attachment is parsed via DMARCParser and counts as a report."""
client = _make_client()
raw = _make_raw_email(
[{"filename": "report.xml", "content": b"<xml_content/>"}]
)
raw = _make_raw_email([{"filename": "report.xml", "content": b"<xml_content/>"}])
msg = email_mod.message_from_bytes(raw)
stats = {"reports_found": 0, "errors": []}
@@ -484,9 +472,7 @@ class TestProcessAttachments:
"""A DMARC-named attachment with truly empty payload is skipped gracefully."""
client = _make_client()
# Build an attachment with empty bytes base64 of b"" is b""
raw = _make_raw_email(
[{"filename": "report.zip", "content": b""}]
)
raw = _make_raw_email([{"filename": "report.zip", "content": b""}])
msg = email_mod.message_from_bytes(raw)
stats = {"reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
@@ -536,9 +522,7 @@ class TestProcessAttachments:
class TestFetchReports:
def test_returns_failure_when_build_service_raises(self):
client = _make_client()
with patch.object(
client, "_build_service", side_effect=Exception("auth error")
):
with patch.object(client, "_build_service", side_effect=Exception("auth error")):
result = client.fetch_reports()
assert result["success"] is False
@@ -547,8 +531,9 @@ class TestFetchReports:
def test_returns_failure_when_list_messages_raises(self):
client = _make_client()
mock_service = MagicMock()
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
client, "_list_dmarc_message_ids", side_effect=Exception("list error")
with (
patch.object(client, "_build_service", return_value=mock_service),
patch.object(client, "_list_dmarc_message_ids", side_effect=Exception("list error")),
):
result = client.fetch_reports()
@@ -557,8 +542,9 @@ class TestFetchReports:
def test_returns_success_with_no_messages(self):
client = _make_client()
mock_service = MagicMock()
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
client, "_list_dmarc_message_ids", return_value=[]
with (
patch.object(client, "_build_service", return_value=mock_service),
patch.object(client, "_list_dmarc_message_ids", return_value=[]),
):
result = client.fetch_reports()
@@ -568,9 +554,11 @@ class TestFetchReports:
def test_skips_already_ingested_messages(self):
client = _make_client(already_ingested=["id1"])
mock_service = MagicMock()
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
), patch.object(client, "_process_message", return_value=0) as mock_proc:
with (
patch.object(client, "_build_service", return_value=mock_service),
patch.object(client, "_list_dmarc_message_ids", return_value=["id1", "id2"]),
patch.object(client, "_process_message", return_value=0) as mock_proc,
):
result = client.fetch_reports()
# Only id2 should be processed; id1 is already ingested
@@ -582,9 +570,11 @@ class TestFetchReports:
def test_tracks_new_ingested_ids(self):
client = _make_client()
mock_service = MagicMock()
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
), patch.object(client, "_process_message", return_value=0):
with (
patch.object(client, "_build_service", return_value=mock_service),
patch.object(client, "_list_dmarc_message_ids", return_value=["id1", "id2"]),
patch.object(client, "_process_message", return_value=0),
):
result = client.fetch_reports()
assert "id1" in result["new_ingested_ids"]
@@ -612,9 +602,11 @@ class TestFetchReports:
stats["reports_found"] += 1
return 1
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
client, "_list_dmarc_message_ids", return_value=["id1"]
), patch.object(client, "_process_message", side_effect=_process_side_effect):
with (
patch.object(client, "_build_service", return_value=mock_service),
patch.object(client, "_list_dmarc_message_ids", return_value=["id1"]),
patch.object(client, "_process_message", side_effect=_process_side_effect),
):
result = client.fetch_reports()
assert "newdomain.example" in result["new_domains"]
+2 -7
View File
@@ -19,7 +19,6 @@ import pytest
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -316,9 +315,7 @@ class TestIsDmarcReportEmail:
def test_dmarc_sender_matches(self):
client = self._make_client()
msg = self._make_msg(
subject="Weekly report", from_addr="noreply@google.com"
)
msg = self._make_msg(subject="Weekly report", from_addr="noreply@google.com")
assert client._is_dmarc_report_email(msg) is True
def test_xml_attachment_matches(self):
@@ -443,9 +440,7 @@ class TestProcessAttachments:
def test_bad_attachment_does_not_raise(self):
client = self._make_client()
msg = email.message_from_bytes(
_make_email_with_attachment("report.xml", b"not xml at all")
)
msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b"not xml at all"))
# Should not raise; just returns 0
count = client._process_attachments(msg)
assert count == 0
+105 -98
View File
@@ -503,9 +503,7 @@ class TestGmailAPIMailSource:
return_value=mock_gmail_client,
):
# First set the access token directly
with patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get:
with patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get:
mock_source = MagicMock()
mock_source.method = "GMAIL_API"
mock_source.gmail_access_token = "valid-token"
@@ -627,10 +625,9 @@ class TestGmailAPIMailSource:
mock_client.fetch_reports.return_value = mock_fetch_results
mock_client.get_refreshed_tokens.return_value = None
with patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get, patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
with (
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
):
mock_source = MagicMock()
mock_source.method = "GMAIL_API"
@@ -843,9 +840,7 @@ class TestGmailCallbackGet:
)
source_id = create_resp.json()["id"]
resp = authed_client.get(
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=xyz"
)
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=xyz")
assert resp.status_code == 404
def test_callback_token_exchange_error_returns_html_400(self, authed_client: TestClient):
@@ -860,9 +855,7 @@ class TestGmailCallbackGet:
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
side_effect=ValueError("bad token"),
):
resp = authed_client.get(
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
)
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
assert resp.status_code == 400
assert "token exchange failed" in resp.text.lower() or "failed" in resp.text.lower()
@@ -879,9 +872,7 @@ class TestGmailCallbackGet:
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={}, # empty no access_token key
):
resp = authed_client.get(
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
)
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
assert resp.status_code == 400
@@ -893,16 +884,17 @@ class TestGmailCallbackGet:
)
source_id = create_resp.json()["id"]
with patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc", "refresh_token": "ref"},
), patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value="user@gmail.com",
with (
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc", "refresh_token": "ref"},
),
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value="user@gmail.com",
),
):
resp = authed_client.get(
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
)
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
assert resp.status_code == 200
assert "connected successfully" in resp.text.lower() or "gmail" in resp.text.lower()
@@ -920,16 +912,17 @@ class TestGmailCallbackGet:
)
source_id = create_resp.json()["id"]
with patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc"}, # no refresh token
), patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value=None,
with (
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc"}, # no refresh token
),
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value=None,
),
):
resp = authed_client.get(
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
)
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
assert resp.status_code == 200
get_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}")
@@ -999,12 +992,15 @@ class TestGmailCallbackPost:
)
source_id = create_resp.json()["id"]
with patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc", "refresh_token": "ref"},
), patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value="user@gmail.com",
with (
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
return_value={"access_token": "acc", "refresh_token": "ref"},
),
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
return_value="user@gmail.com",
),
):
resp = authed_client.post(
f"/api/v1/mail-sources/{source_id}/gmail/callback",
@@ -1052,10 +1048,9 @@ class TestGmailFetchExtra:
mock_client.fetch_reports.return_value = mock_fetch_results
mock_client.get_refreshed_tokens.return_value = None
with patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get, patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
with (
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
):
mock_source = MagicMock()
mock_source.method = "GMAIL_API"
@@ -1099,10 +1094,9 @@ class TestGmailFetchExtra:
"refresh_token": "new_refresh",
}
with patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get, patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
with (
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
):
mock_source = MagicMock()
mock_source.method = "GMAIL_API"
@@ -1141,11 +1135,12 @@ class TestGmailFetchExtra:
mock_client.fetch_reports.return_value = mock_fetch_results
mock_client.get_refreshed_tokens.return_value = None
with patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get, patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
) as mock_gmail_class:
with (
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
) as mock_gmail_class,
):
# Configure the class-level static helpers used inside the endpoint
mock_gmail_class.load_ingested_ids.return_value = ["id1"]
mock_gmail_class.dump_ingested_ids.return_value = '["id1","id2","id3"]'
@@ -1188,18 +1183,19 @@ class TestGmailTestConnectionFailure:
source_id = create_resp.json()["id"]
mock_service = MagicMock()
mock_service.users.return_value.getProfile.return_value.execute.side_effect = (
Exception("internal oauth error: token expired")
mock_service.users.return_value.getProfile.return_value.execute.side_effect = Exception(
"internal oauth error: token expired"
)
mock_gmail_client = MagicMock()
mock_gmail_client._build_service.return_value = mock_service
with patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient",
return_value=mock_gmail_client,
), patch(
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
) as mock_get:
with (
patch(
"app.api.api_v1.endpoints.mail_sources.GmailClient",
return_value=mock_gmail_client,
),
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
):
mock_source = MagicMock()
mock_source.method = "GMAIL_API"
mock_source.gmail_access_token = "tok"
@@ -1274,10 +1270,11 @@ class TestPollSingleGmailSource:
mock_db.__exit__ = MagicMock(return_value=False)
mock_db.query.return_value.get.return_value = mock_db_source
with patch("app.main.GmailClient", return_value=mock_client), patch(
"app.main.SessionLocal", return_value=mock_db
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]), patch(
"app.main.GmailClient.dump_ingested_ids", return_value='["id1","id2"]'
with (
patch("app.main.GmailClient", return_value=mock_client),
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1","id2"]'),
):
_poll_single_gmail_source(src)
@@ -1303,9 +1300,11 @@ class TestPollSingleGmailSource:
mock_db = MagicMock()
mock_db.query.return_value.get.return_value = MagicMock()
with patch("app.main.GmailClient", return_value=mock_client), patch(
"app.main.SessionLocal", return_value=mock_db
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
with (
patch("app.main.GmailClient", return_value=mock_client),
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
):
_poll_single_gmail_source(src) # should not raise
def test_logs_error_on_failure(self):
@@ -1329,9 +1328,11 @@ class TestPollSingleGmailSource:
mock_db = MagicMock()
mock_db.query.return_value.get.return_value = MagicMock()
with patch("app.main.GmailClient", return_value=mock_client), patch(
"app.main.SessionLocal", return_value=mock_db
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
with (
patch("app.main.GmailClient", return_value=mock_client),
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
):
_poll_single_gmail_source(src) # should not raise
def test_persists_refreshed_tokens(self):
@@ -1358,9 +1359,11 @@ class TestPollSingleGmailSource:
mock_db = MagicMock()
mock_db.query.return_value.get.return_value = mock_db_source
with patch("app.main.GmailClient", return_value=mock_client), patch(
"app.main.SessionLocal", return_value=mock_db
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
with (
patch("app.main.GmailClient", return_value=mock_client),
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
):
_poll_single_gmail_source(src)
assert mock_db_source.gmail_access_token == "new-acc"
@@ -1432,9 +1435,11 @@ class TestTriggerPollGmailSource:
mock_gc.get_refreshed_tokens.return_value = None
mock_db = MagicMock()
with patch("app.main.GmailClient", return_value=mock_gc), patch(
"app.main.GmailClient.load_ingested_ids", return_value=[]
), patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1"]'):
with (
patch("app.main.GmailClient", return_value=mock_gc),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1"]'),
):
result = _trigger_poll_gmail_source(src, mock_db)
assert result["success"] is True
@@ -1459,8 +1464,9 @@ class TestTriggerPollGmailSource:
}
mock_db = MagicMock()
with patch("app.main.GmailClient", return_value=mock_gc), patch(
"app.main.GmailClient.load_ingested_ids", return_value=[]
with (
patch("app.main.GmailClient", return_value=mock_gc),
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
):
_trigger_poll_gmail_source(src, mock_db)
@@ -1510,9 +1516,7 @@ class TestPollSourceForTrigger:
src.id = 3
src.name = "Gmail exc"
with patch(
"app.main._trigger_poll_gmail_source", side_effect=Exception("boom")
):
with patch("app.main._trigger_poll_gmail_source", side_effect=Exception("boom")):
result = _poll_source_for_trigger(src, MagicMock())
assert result["success"] is False
@@ -1541,9 +1545,7 @@ class TestPollSourceForTrigger:
src.id = 5
src.name = "IMAP exc"
with patch(
"app.main._trigger_poll_imap_source", side_effect=Exception("imap fail")
):
with patch("app.main._trigger_poll_imap_source", side_effect=Exception("imap fail")):
result = _poll_source_for_trigger(src, MagicMock())
assert result["success"] is False
@@ -1576,9 +1578,10 @@ class TestPollAllEnabledSources:
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [src]
with patch("app.main.SessionLocal", return_value=mock_db), patch(
"app.main._poll_single_gmail_source"
) as mock_gmail:
with (
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main._poll_single_gmail_source") as mock_gmail,
):
_poll_all_enabled_sources()
mock_gmail.assert_called_once_with(src)
@@ -1594,9 +1597,10 @@ class TestPollAllEnabledSources:
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [src]
with patch("app.main.SessionLocal", return_value=mock_db), patch(
"app.main._poll_single_imap_source"
) as mock_imap:
with (
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main._poll_single_imap_source") as mock_imap,
):
_poll_all_enabled_sources()
mock_imap.assert_called_once_with(src)
@@ -1612,8 +1616,9 @@ class TestPollAllEnabledSources:
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [src]
with patch("app.main.SessionLocal", return_value=mock_db), patch(
"app.main._poll_single_gmail_source", side_effect=Exception("crash")
with (
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main._poll_single_gmail_source", side_effect=Exception("crash")),
):
_poll_all_enabled_sources() # should not raise
@@ -1628,8 +1633,9 @@ class TestPollAllEnabledSources:
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [src]
with patch("app.main.SessionLocal", return_value=mock_db), patch(
"app.main._poll_single_imap_source", side_effect=Exception("imap crash")
with (
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main._poll_single_imap_source", side_effect=Exception("imap crash")),
):
_poll_all_enabled_sources() # should not raise
@@ -1653,8 +1659,8 @@ class TestTriggerPollEndpoint:
def test_trigger_poll_with_enabled_sources(self):
"""With enabled sources, the endpoint dispatches and returns results."""
from app.main import app as main_app
from app.core.security import require_admin_auth
from app.main import app as main_app
async def mock_auth():
return {"auth_type": "api_key"}
@@ -1681,8 +1687,9 @@ class TestTriggerPollEndpoint:
}
with TestClient(main_app) as tc:
with patch("app.main.SessionLocal", return_value=mock_db), patch(
"app.main._poll_source_for_trigger", return_value=mock_result
with (
patch("app.main.SessionLocal", return_value=mock_db),
patch("app.main._poll_source_for_trigger", return_value=mock_result),
):
resp = tc.post("/api/v1/admin/trigger-poll")
+32 -8
View File
@@ -15,7 +15,6 @@ from app.core.security import (
verify_token,
)
# ---------------------------------------------------------------------------
# create_access_token
# ---------------------------------------------------------------------------
@@ -84,6 +83,16 @@ class TestVerifyToken:
class TestRequireAdminAuth:
"""Unit tests for the require_admin_auth dependency."""
def _make_request(self, cookies: dict = None):
"""Build a minimal mock Request with optional cookies."""
from unittest.mock import MagicMock
req = MagicMock()
req.cookies = cookies or {}
return req
@pytest.mark.asyncio
async def test_valid_api_key_returns_auth_context(self):
from app.core.security import require_admin_auth
@@ -91,7 +100,9 @@ class TestRequireAdminAuth:
key = generate_api_key()
add_api_key(key)
try:
result = await require_admin_auth(api_key=key, bearer=None)
result = await require_admin_auth(
request=self._make_request(), api_key=key, bearer=None
)
assert result["auth_type"] == "api_key"
finally:
from app.core.security import _api_keys
@@ -106,7 +117,7 @@ class TestRequireAdminAuth:
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
result = await require_admin_auth(api_key=None, bearer=creds)
result = await require_admin_auth(request=self._make_request(), api_key=None, bearer=creds)
assert result["auth_type"] == "jwt"
assert result["payload"]["sub"] == "admin-user"
@@ -117,11 +128,9 @@ class TestRequireAdminAuth:
from app.core.security import require_admin_auth
creds = HTTPAuthorizationCredentials(
scheme="Bearer", credentials="bad.token.value"
)
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bad.token.value")
with pytest.raises(HTTPException) as exc_info:
await require_admin_auth(api_key=None, bearer=creds)
await require_admin_auth(request=self._make_request(), api_key=None, bearer=creds)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
@@ -131,9 +140,24 @@ class TestRequireAdminAuth:
from app.core.security import require_admin_auth
with pytest.raises(HTTPException) as exc_info:
await require_admin_auth(api_key=None, bearer=None)
await require_admin_auth(request=self._make_request(), api_key=None, bearer=None)
assert exc_info.value.status_code == 401
@pytest.mark.asyncio
async def test_valid_session_cookie_returns_auth_context(self):
"""A valid dmarq_session cookie should authenticate successfully."""
from app.core.logto import create_session_token
from app.core.security import require_admin_auth
token = create_session_token(user_id=42)
result = await require_admin_auth(
request=self._make_request(cookies={"dmarq_session": token}),
api_key=None,
bearer=None,
)
assert result["auth_type"] == "session"
assert result["user_id"] == 42
# ---------------------------------------------------------------------------
# get_api_key dependency
+2 -6
View File
@@ -89,18 +89,14 @@ class TestDomainStatistics:
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
assert response.status_code == 200
def test_domain_stats_force_refresh_calls_invalidate_with_domain(
self, client: TestClient
):
def test_domain_stats_force_refresh_calls_invalidate_with_domain(self, client: TestClient):
"""Verify invalidate_cache is called with the domain ID."""
with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
mock_instance = MagicMock()
mock_instance.calculate_summary_statistics.return_value = {"total": 0}
MockSummarizer.return_value = mock_instance
response = client.get(
"/api/v1/stats/domain/example.com?force_refresh=true"
)
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
assert response.status_code == 200
mock_instance.invalidate_cache.assert_called_once_with("example.com")