Merge pull request #83 from christianlouis/copilot/fix-unauthorized-access-settings-api

feat: Logto OIDC authentication + AUTH_DISABLED no-auth fallback
This commit is contained in:
Christian Krakau-Louis
2026-03-30 16:28:55 +02:00
committed by GitHub
20 changed files with 1648 additions and 195 deletions
@@ -0,0 +1,87 @@
"""Add Logto fields to users table.
Adds the columns required for Logto OIDC integration and general user-profile
enhancements:
- ``logto_id`` the Logto subject claim (``sub``); acts as the stable
external identity reference.
- ``username`` optional display username synced from Logto.
- ``picture`` profile-picture URL synced from Logto.
- ``created_at`` row-creation timestamp.
- ``updated_at`` last-update timestamp.
``hashed_password`` is made nullable because Logto-authenticated users
authenticate externally and have no local password.
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-03-30 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, Sequence[str], None] = "c3d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Apply schema changes."""
with op.batch_alter_table("users") as batch_op:
# New columns
batch_op.add_column(
sa.Column("logto_id", sa.String(), nullable=True)
)
batch_op.add_column(
sa.Column("username", sa.String(), nullable=True)
)
batch_op.add_column(
sa.Column("picture", sa.String(), nullable=True)
)
batch_op.add_column(
sa.Column(
"created_at",
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
)
)
batch_op.add_column(
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
)
)
# Make hashed_password nullable (Logto users have no local password)
batch_op.alter_column("hashed_password", nullable=True)
# Set is_superuser default to True (all users are admins for now)
batch_op.alter_column("is_superuser", server_default=sa.true())
# Add unique index on logto_id
op.create_index(
op.f("ix_users_logto_id"),
"users",
["logto_id"],
unique=True,
)
def downgrade() -> None:
"""Revert schema changes."""
op.drop_index(op.f("ix_users_logto_id"), table_name="users")
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("updated_at")
batch_op.drop_column("created_at")
batch_op.drop_column("picture")
batch_op.drop_column("username")
batch_op.drop_column("logto_id")
batch_op.alter_column("hashed_password", nullable=False)
batch_op.alter_column("is_superuser", server_default=sa.false())
+2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import (
auth,
domains,
health,
imap,
@@ -14,6 +15,7 @@ from app.api.api_v1.endpoints import (
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(health.router, tags=["health"])
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
+258
View File
@@ -0,0 +1,258 @@
"""
Authentication endpoints (Logto OIDC).
Routes
------
GET /sign-in Initiate the Logto sign-in flow.
GET /callback Handle the Logto authorization-code callback.
GET /sign-out Sign the user out (clears session + redirects to Logto).
GET /me Return the currently authenticated user's profile.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.logto import (
SESSION_COOKIE,
CookieStorage,
create_session_token,
decode_session_token,
make_logto_client,
sync_logto_user,
)
from app.models.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Helpers ───────────────────────────────────────────────────────────────────
_SAFE_NEXT_PREFIXES = ("/",) # only allow relative redirects after login
def _safe_next(next_url: Optional[str]) -> str:
"""Validate and return a safe post-login redirect path."""
if next_url and next_url.startswith("/") and not next_url.startswith("//"):
return next_url
return "/"
def _logto_not_configured() -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Logto is not configured. "
"Set LOGTO_ENDPOINT, LOGTO_APP_ID, and LOGTO_APP_SECRET "
"in your environment."
),
)
def _get_redirect_uri(request: Request) -> str:
"""Build the callback redirect URI, preferring the configured override."""
if settings.LOGTO_REDIRECT_URI:
return settings.LOGTO_REDIRECT_URI
base = str(request.base_url).rstrip("/")
return f"{base}/api/v1/auth/callback"
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/sign-in")
async def sign_in(
request: Request,
next: Optional[str] = None,
) -> RedirectResponse:
"""
Initiate the Logto OIDC sign-in flow.
Stores the PKCE sign-in session in a short-lived cookie and redirects the
browser to Logto's authorization endpoint. The optional ``next`` query
parameter is persisted in a separate cookie and used to redirect the user
to their original page after a successful login.
"""
if not settings.logto_configured:
raise _logto_not_configured()
storage = CookieStorage(request)
client = make_logto_client(storage)
sign_in_url: str = await client.signIn(redirectUri=_get_redirect_uri(request))
response = RedirectResponse(url=sign_in_url, status_code=302)
storage.apply_to_response(response)
# Persist the post-login destination so the callback can redirect there.
safe = _safe_next(next)
if safe != "/":
response.set_cookie(
key="logto_next",
value=safe,
httponly=True,
samesite="lax",
max_age=600, # 10 minutes must survive the Logto redirect round-trip
)
return response
@router.get("/callback")
async def callback(
request: Request,
db: Session = Depends(get_db),
) -> RedirectResponse:
"""
Handle the Logto authorization-code callback.
Exchanges the code for tokens, validates the ID token, upserts the local
user shadow record, issues the app-level session cookie, and clears the
temporary Logto cookies.
"""
if not settings.logto_configured:
raise _logto_not_configured()
storage = CookieStorage(request)
client = make_logto_client(storage)
try:
await client.handleSignInCallback(str(request.url))
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Logto callback error: %s", exc)
return RedirectResponse(url="/login?error=callback_failed", status_code=302)
try:
claims = await client.getIdTokenClaims()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Failed to extract ID-token claims: %s", exc)
return RedirectResponse(url="/login?error=token_error", status_code=302)
user = sync_logto_user(claims, db)
# Where to go after login
next_url = _safe_next(request.cookies.get("logto_next"))
response = RedirectResponse(url=next_url, status_code=302)
# Issue our own session cookie (independent of Logto from here on)
session_token = create_session_token(user.id)
response.set_cookie(
key=SESSION_COOKIE,
value=session_token,
httponly=True,
samesite="lax",
max_age=86_400, # 24 hours
)
# Clean up all temporary Logto & next cookies
storage.clear_all_logto_cookies(response)
response.delete_cookie(key="logto_next", httponly=True, samesite="lax")
logger.info("User id=%d logged in via Logto.", user.id)
return response
@router.get("/sign-out")
async def sign_out(request: Request) -> RedirectResponse:
"""
Sign the user out.
When ``AUTH_DISABLED=true`` there is nothing to sign out of; redirects to ``/``.
Otherwise clears the app session cookie and redirects to Logto's end-session
endpoint (if available) so that the Logto session is terminated too.
"""
if settings.AUTH_DISABLED:
return RedirectResponse(url="/", status_code=302)
post_logout_url = str(request.base_url).rstrip("/")
# Best-effort: obtain Logto's end-session URL from OIDC metadata.
end_session_url: Optional[str] = None
if settings.logto_configured:
try:
storage = CookieStorage(request)
client = make_logto_client(storage)
core = await client.getOidcCore()
end_session_url = getattr(core.metadata, "end_session_endpoint", None)
except Exception: # pylint: disable=broad-exception-caught
pass
if end_session_url:
redirect_to = f"{end_session_url}?post_logout_redirect_uri={post_logout_url}"
else:
redirect_to = "/login"
response = RedirectResponse(url=redirect_to, status_code=302)
response.delete_cookie(key=SESSION_COOKIE, httponly=True, samesite="lax")
return response
@router.get("/me", response_model=None)
async def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""
Return the profile of the currently authenticated user.
When ``AUTH_DISABLED=true`` a synthetic anonymous-admin profile is returned
so that UI components (e.g. the navbar user menu) work without a real session.
Otherwise reads the ``dmarq_session`` cookie (issued at callback time) and
looks up the corresponding local ``User`` record.
"""
# Auth-disabled: return a synthetic profile so the UI renders correctly.
if settings.AUTH_DISABLED:
return {
"id": 0,
"email": "admin@localhost",
"full_name": "Local Admin",
"username": "admin",
"picture": None,
"is_superuser": True,
"logto_id": None,
"auth_disabled": True,
}
token = request.cookies.get(SESSION_COOKIE)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user_id = decode_session_token(token)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
user: Optional[User] = (
db.query(User).filter(User.id == user_id, User.is_active == True).first() # noqa: E712
)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"username": user.username,
"picture": user.picture,
"is_superuser": user.is_superuser,
"logto_id": user.logto_id,
}
+30 -1
View File
@@ -54,9 +54,38 @@ class Settings(BaseSettings):
# Use: openssl rand -hex 32
ADMIN_API_KEY: Optional[str] = None
# ── Authentication mode ───────────────────────────────────────────────────
# Set AUTH_DISABLED=true to run without any authentication.
# Every request is treated as an anonymous admin.
#
# ⚠️ Only use this for local development or deployments that are protected
# by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth).
# Never expose an AUTH_DISABLED instance directly to the internet.
AUTH_DISABLED: bool = False
# ── Logto OIDC ────────────────────────────────────────────────────────────
# Set these to enable Logto-based authentication.
# LOGTO_ENDPOINT: the base URL of your Logto instance,
# e.g. "https://your-tenant.logto.app" or a self-hosted URL.
# LOGTO_APP_ID: the Client ID of the "Traditional Web" application in Logto.
# LOGTO_APP_SECRET: the Client Secret of the same application.
# LOGTO_REDIRECT_URI (optional): override the default callback URL.
# Defaults to <base_url>/api/v1/auth/callback.
LOGTO_ENDPOINT: Optional[str] = None
LOGTO_APP_ID: Optional[str] = None
LOGTO_APP_SECRET: Optional[str] = None
LOGTO_REDIRECT_URI: Optional[str] = None
@property
def logto_configured(self) -> bool:
"""Return True when the minimum Logto settings are present."""
return bool(self.LOGTO_ENDPOINT and self.LOGTO_APP_ID and self.LOGTO_APP_SECRET)
@validator("ADMIN_API_KEY", pre=True, always=True)
@classmethod
def validate_admin_api_key(cls, v: Optional[str]) -> Optional[str]: # pylint: disable=no-self-argument
def validate_admin_api_key(
cls, v: Optional[str]
) -> Optional[str]: # pylint: disable=no-self-argument
"""Warn if ADMIN_API_KEY is set but too short."""
if v is not None and len(v) < 32:
logger.warning(
+218
View File
@@ -0,0 +1,218 @@
"""
Logto OIDC integration helpers.
Provides:
- ``CookieStorage`` Logto SDK Storage adapter backed by HTTP cookies.
- ``make_logto_client`` Factory that builds a per-request LogtoClient.
- ``create_session_token``/``decode_session_token`` thin JWT helpers for the
app-level session cookie (independent of Logto after the initial callback).
- ``sync_logto_user`` Upserts the local User shadow record from Logto claims.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Request, Response
from jose import JWTError, jwt
from logto import IdTokenClaims, LogtoClient, LogtoConfig, PersistKey, Scope, Storage, UserInfoScope
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.user import User
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Constants ────────────────────────────────────────────────────────────────
SESSION_COOKIE = "dmarq_session"
# Short-lived: only needed while the browser is being redirected to Logto and back.
_SIGN_IN_SESSION_MAX_AGE = 600 # 10 minutes
# The app-level session lasts 24 hours by default; the Logto ID-token has its own
# expiry but we don't keep it in the browser beyond the callback request.
_SESSION_MAX_AGE = 86_400 # 24 hours
# ── Cookie-backed Logto Storage ───────────────────────────────────────────────
class CookieStorage(Storage):
"""
Storage adapter for the Logto SDK that persists the OIDC session data
(sign-in session, tokens) in HTTP-only cookies.
Usage::
storage = CookieStorage(request)
client = make_logto_client(storage)
url = await client.signIn(redirect_uri=…)
# build a response, then:
storage.apply_to_response(response)
return response
"""
_COOKIE_PREFIX = "logto_"
def __init__(self, request: Request) -> None:
self._request = request
# Pending writes/deletes applied to the Response via apply_to_response().
self._writes: dict[str, Optional[str]] = {}
self._deletes: set[str] = set()
# ── Storage protocol ──────────────────────────────────────────────────────
def get(self, key: PersistKey) -> Optional[str]: # type: ignore[override]
if key in self._writes:
return self._writes[key]
if key in self._deletes:
return None
return self._request.cookies.get(self._COOKIE_PREFIX + key)
def set(self, key: PersistKey, value: Optional[str]) -> None: # type: ignore[override]
self._writes[key] = value
self._deletes.discard(key)
def delete(self, key: PersistKey) -> None: # type: ignore[override]
self._deletes.add(key)
self._writes.pop(key, None)
# ── Response helper ───────────────────────────────────────────────────────
def apply_to_response(self, response: Response) -> None:
"""Flush pending cookie mutations onto *response*."""
for key, value in self._writes.items():
if value is None:
continue
max_age = _SIGN_IN_SESSION_MAX_AGE if key == "signInSession" else _SESSION_MAX_AGE
response.set_cookie(
key=self._COOKIE_PREFIX + key,
value=value,
httponly=True,
samesite="lax",
max_age=max_age,
)
for key in self._deletes:
response.delete_cookie(
key=self._COOKIE_PREFIX + key,
httponly=True,
samesite="lax",
)
def clear_all_logto_cookies(self, response: Response) -> None:
"""Remove every Logto cookie (called after we've issued our own session)."""
for key in ("signInSession", "idToken", "accessTokenMap", "refreshToken"):
response.delete_cookie(
key=self._COOKIE_PREFIX + key,
httponly=True,
samesite="lax",
)
# ── LogtoClient factory ───────────────────────────────────────────────────────
def make_logto_client(storage: CookieStorage) -> LogtoClient:
"""Return a per-request ``LogtoClient`` bound to *storage*."""
return LogtoClient(
LogtoConfig(
endpoint=settings.LOGTO_ENDPOINT or "",
appId=settings.LOGTO_APP_ID or "",
appSecret=settings.LOGTO_APP_SECRET,
scopes=[
UserInfoScope.email,
UserInfoScope.profile,
Scope.offlineAccess,
],
),
storage=storage,
)
# ── App-level session JWT (independent of Logto after first login) ────────────
def create_session_token(user_id: int) -> str:
"""Mint a signed HS256 JWT for *user_id* with a 24-hour lifetime."""
payload = {
"sub": str(user_id),
"type": "dmarq_session",
"exp": datetime.utcnow() + timedelta(seconds=_SESSION_MAX_AGE),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_session_token(token: str) -> Optional[int]:
"""
Validate *token* and return the user's local DB id.
Returns ``None`` on any error (expired, wrong type, bad signature, …).
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
if payload.get("type") != "dmarq_session":
return None
return int(payload["sub"])
except (JWTError, ValueError, TypeError):
return None
# ── Local user sync ───────────────────────────────────────────────────────────
def sync_logto_user(claims: IdTokenClaims, db: Session) -> User:
"""
Upsert the local ``User`` shadow record from Logto ID-token claims.
Lookup order:
1. Match on ``logto_id`` (``sub`` claim) fastest, stable.
2. Fall back to matching on email if the user was created before Logto
integration and doesn't have a ``logto_id`` yet.
3. Create a brand-new record if neither match.
All users are treated as admins (``is_superuser=True``) until RBAC is
added in a future milestone.
"""
logto_id: str = claims.sub
email: str = claims.email or f"{logto_id}@logto.local"
# 1. Try existing Logto-linked user
user: Optional[User] = db.query(User).filter(User.logto_id == logto_id).first()
if user is None:
# 2. Try to link a legacy user by email
user = db.query(User).filter(User.email == email).first()
if user is not None:
user.logto_id = logto_id
logger.info(
"Linked existing user id=%d (%s) to Logto sub=%s",
user.id,
email,
logto_id,
)
if user is None:
# 3. Create new user
user = User(
logto_id=logto_id,
email=email,
is_active=True,
is_superuser=True,
is_verified=bool(getattr(claims, "email_verified", False)),
)
db.add(user)
db.flush() # populate user.id before commit
logger.info("Created new user id=%d from Logto sub=%s (%s)", user.id, logto_id, email)
# Always refresh profile from latest claims
user.full_name = getattr(claims, "name", None) or user.full_name
user.username = getattr(claims, "username", None) or user.username
user.picture = getattr(claims, "picture", None) or user.picture
user.updated_at = datetime.utcnow()
db.commit()
db.refresh(user)
return user
+35 -17
View File
@@ -4,7 +4,7 @@ import secrets
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from fastapi import HTTPException, Security, status
from fastapi import HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
@@ -162,42 +162,60 @@ async def verify_token(
async def require_admin_auth(
request: Request,
api_key: Optional[str] = Security(api_key_header),
bearer: Optional[HTTPAuthorizationCredentials] = Security(security_bearer),
) -> dict:
"""
Dependency to require either API key or JWT token authentication for admin endpoints.
Dependency to require authentication for admin/API endpoints.
Checks API key first, then falls back to JWT token.
Accepts (in priority order):
1. ``AUTH_DISABLED=true`` env var passes through with a synthetic context.
2. ``dmarq_session`` cookie set after a successful Logto login.
3. ``X-API-Key`` header static admin key for programmatic access.
4. ``Authorization: Bearer <token>`` header app-issued JWT.
Args:
api_key: Optional API key from X-API-Key header
bearer: Optional JWT token from Authorization header
Returns:
Authentication context (api_key or token payload)
Raises:
HTTPException: If no valid authentication is provided
Returns an authentication context dict describing how the request was
authenticated. Raises ``HTTP 401`` when no valid credential is present.
"""
# Try API key first
# 0. Auth globally disabled
if settings.AUTH_DISABLED:
return {"auth_type": "disabled"}
# 1. Session cookie (Logto-backed app session)
from app.core.logto import SESSION_COOKIE, decode_session_token # local import
session_token = request.cookies.get(SESSION_COOKIE)
if session_token:
user_id = decode_session_token(session_token)
if user_id is not None:
return {"auth_type": "session", "user_id": user_id}
# 2. Static admin API key
if api_key and verify_api_key(api_key):
return {"auth_type": "api_key", "api_key": api_key}
# Try JWT token
# 3. Bearer JWT (app-issued; also covers Bearer tokens set by older clients)
if bearer:
from app.core.logto import decode_session_token as _dec # local import
user_id = _dec(bearer.credentials)
if user_id is not None:
return {"auth_type": "bearer", "user_id": user_id}
# Fallback: legacy python-jose JWT (pre-Logto API keys / CI tokens)
try:
payload = jwt.decode(
bearer.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return {"auth_type": "jwt", "payload": payload}
except JWTError as e:
logger.warning("Invalid JWT token: %s", str(e))
logger.warning("Invalid Bearer JWT: %s", str(e))
# No valid authentication provided
# No valid authentication
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Provide either X-API-Key header or Bearer token.",
detail="Authentication required. Provide a session cookie, X-API-Key header, or Bearer token.",
headers={"WWW-Authenticate": "ApiKey, Bearer"},
)
+37 -7
View File
@@ -17,10 +17,11 @@ from app.api.api_v1.api import api_router
from app.core.config import get_settings
from app.core.database import Base, SessionLocal, engine
from app.core.security import add_api_key, generate_api_key, require_admin_auth
from app.middleware.auth import AuthRedirectMiddleware
from app.middleware.security import SecurityHeadersMiddleware
from app.models.mail_source import MailSource # noqa: F401 ensure table is registered
from app.services.gmail_client import GmailClient
from app.models.user import User # noqa: F401 ensure User mapper is registered
from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
@@ -255,6 +256,9 @@ def create_app() -> FastAPI:
environment = os.getenv("ENVIRONMENT", "development")
application.add_middleware(SecurityHeadersMiddleware, environment=environment)
# Auth redirect middleware protects HTML pages; must sit outside CORS
application.add_middleware(AuthRedirectMiddleware)
# Improved CORS configuration - restrict to specific methods and headers
if settings.BACKEND_CORS_ORIGINS:
application.add_middleware(
@@ -296,6 +300,18 @@ def create_app() -> FastAPI:
# Ensure all tables exist (no-op if already present)
Base.metadata.create_all(bind=engine)
# Warn loudly when authentication is completely disabled
if settings.AUTH_DISABLED:
logger.warning(
"%s\n"
"⚠️ AUTH_DISABLED=true — authentication is turned OFF.\n"
"All requests have unrestricted admin access.\n"
"Do NOT expose this instance directly to the internet.\n"
"%s",
"=" * 80,
"=" * 80,
)
# Load or generate the admin API key
if settings.ADMIN_API_KEY:
api_key = settings.ADMIN_API_KEY
@@ -362,13 +378,29 @@ async def dashboard(request: Request):
@app.get("/login", response_class=HTMLResponse)
async def login(request: Request):
return templates.TemplateResponse(request, "login.html", {"app_name": settings.PROJECT_NAME})
async def login(request: Request, next: str = "/"):
return templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.PROJECT_NAME,
"logto_configured": settings.logto_configured,
"auth_disabled": settings.AUTH_DISABLED,
"next": next,
},
)
@app.get("/setup", response_class=HTMLResponse)
async def setup(request: Request):
return templates.TemplateResponse(request, "setup.html", {"app_name": settings.PROJECT_NAME})
return templates.TemplateResponse(
request,
"setup.html",
{
"app_name": settings.PROJECT_NAME,
"logto_configured": settings.logto_configured,
},
)
@app.get("/domains", response_class=HTMLResponse)
@@ -418,9 +450,7 @@ async def reports(request: Request):
@app.get("/reports/{report_id}", response_class=HTMLResponse)
async def report_detail(request: Request, report_id: str):
"""View detailed information for a specific DMARC report"""
return templates.TemplateResponse(
request, "report_detail.html", {"report_id": report_id}
)
return templates.TemplateResponse(request, "report_detail.html", {"report_id": report_id})
@app.get("/settings", response_class=HTMLResponse)
+85
View File
@@ -0,0 +1,85 @@
"""
Authentication redirect middleware.
Intercepts browser requests for protected HTML pages and redirects
unauthenticated visitors to ``/login`` (or ``/setup`` if Logto is not yet
configured).
API routes (``/api/…``) are intentionally left to handle their own 401
responses so that programmatic clients are not broken.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.types import ASGIApp
from app.core.logto import SESSION_COOKIE, decode_session_token
# Paths that are always publicly accessible
_PUBLIC_PATHS: frozenset[str] = frozenset(
{
"/login",
"/setup",
"/health",
"/healthz",
}
)
# Request path prefixes that bypass auth checks
_PUBLIC_PREFIXES: tuple[str, ...] = (
"/api/",
"/static/",
"/docs",
"/redoc",
"/openapi",
)
class AuthRedirectMiddleware(BaseHTTPMiddleware):
"""
Redirect unauthenticated browser requests to the appropriate page.
Decision tree
-------------
1. Path is public → pass through.
2. Session cookie present and valid → pass through.
3. Logto not configured → redirect to ``/setup``.
4. Otherwise → redirect to ``/login?next=<original_path>``.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
path = request.url.path
# ── 0. Auth disabled globally ─────────────────────────────────────────
from app.core.config import get_settings # local import avoids circular dep
cfg = get_settings()
if cfg.AUTH_DISABLED:
return await call_next(request)
# ── 1. Public paths & prefixes ────────────────────────────────────────
if path in _PUBLIC_PATHS:
return await call_next(request)
if any(path.startswith(p) for p in _PUBLIC_PREFIXES):
return await call_next(request)
# ── 2. Valid session cookie ───────────────────────────────────────────
token = request.cookies.get(SESSION_COOKIE)
if token and decode_session_token(token) is not None:
return await call_next(request)
# ── 3. Logto not configured ───────────────────────────────────────────
if not cfg.logto_configured:
return RedirectResponse(url="/setup", status_code=302)
# ── 4. Redirect to login ──────────────────────────────────────────────
next_path = request.url.path
if request.url.query:
next_path = f"{next_path}?{request.url.query}"
return RedirectResponse(url=f"/login?next={next_path}", status_code=302)
+25 -5
View File
@@ -1,24 +1,44 @@
from sqlalchemy import Boolean, Column, Integer, String
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
"""User model"""
"""User model local shadow of the identity managed by Logto."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
# Logto subject claim (the user's stable ID inside Logto).
# Null for users that pre-date Logto integration or for
# programmatic/service accounts created directly in the DB.
logto_id = Column(String, unique=True, index=True, nullable=True)
# hashed_password kept for possible future local-auth fallback; nullable
# because Logto users authenticate externally and have no local password.
hashed_password = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# For now all users are treated as admin. RBAC tiers are planned.
is_superuser = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
# Additional fields
# Profile synced from Logto claims on every login
full_name = Column(String, nullable=True)
username = Column(String, nullable=True)
organization = Column(String, nullable=True)
picture = Column(String, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=True)
updated_at = Column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=True,
)
# Relationships
user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan")
+57 -2
View File
@@ -19,7 +19,8 @@
</head>
<body class="min-h-screen bg-base-100 font-body antialiased">
<!-- Updated Menu Bar -->
<header class="navbar bg-primary text-primary-content">
<header class="navbar bg-primary text-primary-content"
x-data="userMenu()" x-init="loadUser()">
<div class="flex-1">
<a href="/" class="btn btn-ghost normal-case text-xl">
<img src="/static/img/monogram_light.png" alt="DMARQ Logo" class="w-8 h-8 mr-2">
@@ -35,6 +36,41 @@
<li><a href="/mail-sources">Mail Sources</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
<!-- User menu -->
<div class="ml-2">
<template x-if="user">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar placeholder">
<div class="bg-primary-content text-primary rounded-full w-8">
<template x-if="user.picture">
<img :src="user.picture" :alt="user.full_name || user.email" class="rounded-full w-8 h-8 object-cover">
</template>
<template x-if="!user.picture">
<span class="text-sm font-semibold" x-text="(user.full_name || user.email || '?')[0].toUpperCase()"></span>
</template>
</div>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 text-base-content rounded-box w-56">
<li class="menu-title px-2 py-1">
<span class="text-xs font-semibold truncate" x-text="user.full_name || user.email"></span>
<span class="text-xs text-base-content/50 truncate" x-text="user.email" x-show="user.full_name"></span>
</li>
<li><a href="/settings">Settings</a></li>
<li>
<a href="/api/v1/auth/sign-out" class="text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign out
</a>
</li>
</ul>
</div>
</template>
<template x-if="!user">
<a href="/login" class="btn btn-ghost btn-sm">Sign in</a>
</template>
</div>
</div>
</header>
@@ -45,7 +81,26 @@
<!-- Scripts -->
{% block scripts %}{% endblock %}
<!-- User-menu Alpine component -->
<script>
function userMenu() {
return {
user: null,
async loadUser() {
try {
const res = await fetch('/api/v1/auth/me');
if (res.ok) {
this.user = await res.json();
}
} catch (_) {
// silently ignore user is simply not shown
}
},
};
}
</script>
<!-- Initialize theme from localStorage -->
<script>
document.addEventListener('DOMContentLoaded', function() {
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en" data-theme="dmarqlight">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In {{ app_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200 flex items-center justify-center font-sans antialiased">
<div class="w-full max-w-md px-4">
<!-- Logo / brand -->
<div class="text-center mb-8">
<a href="/" class="inline-flex items-center gap-3">
<img src="/static/img/monogram_light.png" alt="{{ app_name }} logo" class="w-12 h-12">
<span class="text-3xl font-bold text-primary font-heading">{{ app_name }}</span>
</a>
<p class="mt-2 text-base-content/60 text-sm">DMARC Monitoring &amp; Analysis</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body gap-6">
<h1 class="card-title text-xl justify-center">Sign in to your account</h1>
{% if auth_disabled %}
<!-- Auth disabled mode -->
<div role="alert" class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
<div>
<p class="font-semibold">Authentication is disabled</p>
<p class="text-sm">
<code class="font-mono bg-base-200 px-1 rounded">AUTH_DISABLED=true</code>
is set. All requests have full access — no sign-in required.
<a href="/" class="link link-info font-medium">Go to dashboard →</a>
</p>
</div>
</div>
{% elif not logto_configured %}
<!-- Logto not yet configured -->
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<div>
<p class="font-semibold">Authentication not configured</p>
<p class="text-sm">Logto is not set up yet. Please visit the
<a href="/setup" class="link link-warning font-medium">setup page</a>
for configuration instructions.
</p>
</div>
</div>
{% else %}
<!-- Error banner (shown when ?error= is present) -->
<div x-data="{ error: new URLSearchParams(window.location.search).get('error') }"
x-show="error" x-cloak>
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<div>
<p class="font-semibold">Sign-in failed</p>
<p class="text-sm" x-text="error === 'callback_failed'
? 'The authentication callback failed. Please try again.'
: error === 'token_error'
? 'Could not read authentication token. Please try again.'
: 'An unexpected error occurred. Please try again.'">
</p>
</div>
</div>
</div>
<!-- Primary sign-in button -->
<div class="space-y-3">
<a href="/api/v1/auth/sign-in?next={{ next | urlencode }}"
class="btn btn-primary btn-lg w-full gap-2">
<!-- Logto "shield" icon approximation -->
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 2L4 5v6c0 5.25 3.5 9.74 8 11 4.5-1.26 8-5.75 8-11V5L12 2z"/>
</svg>
Sign in with Logto
</a>
<p class="text-center text-xs text-base-content/50">
Logto securely handles authentication.
Your credentials are never sent to {{ app_name }}.
</p>
</div>
<div class="divider text-xs text-base-content/40">What is Logto?</div>
<div class="text-sm text-base-content/60 space-y-1">
<p>
<a href="https://logto.io" target="_blank" rel="noopener" class="link link-primary">Logto</a>
is an open-source identity platform that supports email/password login,
social providers (Google, GitHub, …), passkeys, and multi-factor authentication.
</p>
<p>
It can be self-hosted alongside {{ app_name }} or used via the
<a href="https://cloud.logto.io" target="_blank" rel="noopener" class="link link-primary">Logto Cloud</a>
free tier.
</p>
</div>
{% endif %}
</div>
</div>
<p class="text-center mt-6 text-xs text-base-content/40">
&copy; {{ app_name }} DMARC Monitoring Platform
</p>
</div>
<script>
// Restore dark-mode preference from localStorage (mirrors base.html logic)
(function () {
if (localStorage.getItem('darkMode') === 'true') {
document.documentElement.setAttribute('data-theme', 'dmarqdark');
}
})();
</script>
</body>
</html>
+7 -2
View File
@@ -342,14 +342,19 @@ function settingsApp() {
showCfToken: false,
showSmtpPw: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
// No manual auth header needed for API calls from the UI.
apiHeaders() {
const key = localStorage.getItem('adminApiKey') || '';
return { 'Content-Type': 'application/json', 'X-API-Key': key };
return { 'Content-Type': 'application/json' };
},
async loadSettings() {
try {
const res = await fetch('/api/v1/settings', { headers: this.apiHeaders() });
if (res.status === 401 || res.status === 403) {
window.location.href = '/login?next=/settings';
return;
}
if (!res.ok) {
this.showFlash('Failed to load settings: ' + res.statusText, false);
return;
+207
View File
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en" data-theme="dmarqlight">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup {{ app_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200 font-sans antialiased">
<div class="max-w-3xl mx-auto px-4 py-12">
<!-- Logo / brand -->
<div class="text-center mb-10">
<a href="/" class="inline-flex items-center gap-3">
<img src="/static/img/monogram_light.png" alt="{{ app_name }} logo" class="w-12 h-12">
<span class="text-3xl font-bold text-primary font-heading">{{ app_name }}</span>
</a>
<h1 class="mt-3 text-2xl font-semibold">First-run Setup</h1>
<p class="mt-1 text-base-content/60 text-sm">Configure Logto to enable user authentication.</p>
</div>
{% if logto_configured %}
<!-- Already configured -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div role="alert" class="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<div>
<p class="font-semibold">Logto is configured!</p>
<p class="text-sm">Authentication is ready. You can now
<a href="/login" class="link link-success font-medium">sign in</a>.
</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Step-by-step guide -->
<div class="space-y-6">
<!-- Step 1 -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<span class="badge badge-primary badge-lg mr-2">1</span>
Deploy or sign up for Logto
</h2>
<p class="text-sm text-base-content/70">
Choose one of the options below. Both are free to start.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-2">
<div class="border border-base-300 rounded-lg p-4 space-y-2">
<p class="font-semibold text-sm">☁️ Logto Cloud (recommended)</p>
<p class="text-xs text-base-content/60">
Sign up at
<a href="https://cloud.logto.io" target="_blank" rel="noopener" class="link link-primary">cloud.logto.io</a>.
The free tier supports unlimited users.
</p>
</div>
<div class="border border-base-300 rounded-lg p-4 space-y-2">
<p class="font-semibold text-sm">🐳 Self-hosted (Docker)</p>
<p class="text-xs text-base-content/60">
Add Logto to your <code class="font-mono">docker-compose.yml</code>
(see the
<a href="https://docs.logto.io/docs/recipes/deployment/" target="_blank" rel="noopener" class="link link-primary">Logto deployment guide</a>).
</p>
</div>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<span class="badge badge-primary badge-lg mr-2">2</span>
Create a "Traditional Web" application in Logto
</h2>
<ol class="list-decimal list-inside text-sm text-base-content/70 space-y-1 mt-1">
<li>Open the <strong>Logto Console</strong><em>Applications</em><em>Create application</em>.</li>
<li>Choose <strong>Traditional Web</strong>.</li>
<li>Enter a name, e.g. <em>DMARQ</em>.</li>
<li>
Set the <strong>Redirect URI</strong> to:<br>
<code class="font-mono bg-base-200 px-2 py-0.5 rounded text-xs break-all">
&lt;your-dmarq-url&gt;/api/v1/auth/callback
</code>
</li>
<li>
Set the <strong>Post Sign-out Redirect URI</strong> to:<br>
<code class="font-mono bg-base-200 px-2 py-0.5 rounded text-xs break-all">
&lt;your-dmarq-url&gt;/login
</code>
</li>
<li>Save and note the <strong>App ID</strong> and <strong>App Secret</strong>.</li>
</ol>
</div>
</div>
<!-- Step 3 -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<span class="badge badge-primary badge-lg mr-2">3</span>
Set environment variables and restart {{ app_name }}
</h2>
<p class="text-sm text-base-content/70 mb-3">
Add the following to your <code class="font-mono">.env</code> file or Docker environment:
</p>
<div class="mockup-code text-xs">
<pre><code># Logto endpoint the base URL of your Logto instance
LOGTO_ENDPOINT=https://&lt;your-tenant&gt;.logto.app
# Application credentials from the Logto Console
LOGTO_APP_ID=&lt;your-app-id&gt;
LOGTO_APP_SECRET=&lt;your-app-secret&gt;
# Optional: override the callback URL (defaults to &lt;base_url&gt;/api/v1/auth/callback)
# LOGTO_REDIRECT_URI=https://dmarc.example.com/api/v1/auth/callback</code></pre>
</div>
<div role="alert" class="alert alert-info mt-4 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
<p>
For Docker Compose, set these in the <code class="font-mono">environment:</code> section
of the <em>backend</em> service and run <code class="font-mono">docker compose up -d --force-recreate</code>.
</p>
</div>
</div>
</div>
<!-- Step 4 -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-lg">
<span class="badge badge-primary badge-lg mr-2">4</span>
(Optional) Add authentication providers in Logto
</h2>
<p class="text-sm text-base-content/70">
Logto lets you enable social providers (Google, GitHub, Microsoft, …),
SMS / email passwordless, and multi-factor authentication entirely through
its console no code changes needed in {{ app_name }}.
</p>
<p class="text-sm text-base-content/70 mt-2">
See the
<a href="https://docs.logto.io/docs/recipes/configure-connectors/" target="_blank" rel="noopener" class="link link-primary">Logto connector docs</a>
for details.
</p>
</div>
</div>
<!-- Alternative: disable auth entirely -->
<div class="card bg-base-100 shadow border border-warning/40">
<div class="card-body">
<h2 class="card-title text-lg text-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
Alternative: disable authentication entirely
</h2>
<p class="text-sm text-base-content/70">
If you're running {{ app_name }} locally or behind a trusted reverse proxy that
already handles authentication, you can skip Logto and grant everyone full access
by setting:
</p>
<div class="mockup-code text-xs mt-2">
<pre><code>AUTH_DISABLED=true</code></pre>
</div>
<div role="alert" class="alert alert-warning mt-3 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<p>
<strong>Never</strong> set <code class="font-mono">AUTH_DISABLED=true</code> on a
publicly reachable instance. Anyone with network access will have full admin access.
</p>
</div>
</div>
</div>
{% if logto_configured %}
<div class="text-center mt-4">
<a href="/login" class="btn btn-primary btn-lg">Go to Sign-in</a>
</div>
{% endif %}
</div><!-- /space-y-6 -->
</div>
<script>
(function () {
if (localStorage.getItem('darkMode') === 'true') {
document.documentElement.setAttribute('data-theme', 'dmarqdark');
}
})();
</script>
</body>
</html>
+295
View File
@@ -0,0 +1,295 @@
"""
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
# ── 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
+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")
+2 -1
View File
@@ -6,7 +6,8 @@ pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
fastapi-users[sqlalchemy]>=12.0.0
logto>=0.2.0
aiohttp>=3.8.0
alembic>=1.11.0
pytest>=7.3.1
pytest-asyncio>=0.21.0