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:
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user