deer-flow/backend/tests/blocking_io/test_auth_router_config_reads.py
Hyeonsang Cho 0479934fe2
fix(auth): read the live config off the event loop on public auth routes (#7576)
* fix(auth): read the live config off the event loop on public auth routes

/register, /setup-status, /providers, /oauth/{provider} and
/callback/{provider} read the live auth config through get_app_config()
inline. That call stats and hashes config.yaml and extensions_config.json
on every request and reparses them after an edit, and these routes are
unauthenticated, so any client could keep that file IO on the event
loop. /login/local already reads its throttle policy in a worker.

Read the registration gate through asyncio.to_thread, and the SSO
routes' auth.oidc section through a shared _oidc_config() helper that
does the same.

* docs(changelog): reference #7576 for the off-loop auth route config reads

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 22:11:48 +08:00

135 lines
5.3 KiB
Python

"""Regression anchors: the public auth routes must read the live config off the loop.
``get_app_config()`` stats and re-hashes ``config.yaml`` and
``extensions_config.json`` on every call, and reparses them after an edit.
``/register``, ``/setup-status``, ``/providers``, ``/oauth/{provider}`` and
``/callback/{provider}`` are unauthenticated and each read the live ``auth``
section, so the read runs in a worker, as ``/login/local``'s throttle policy
does (``test_login_local_throttle.py``).
Each request runs against real config files and asserts an answer only the
file on disk produces (registration closed, the configured SSO provider), so an
anchor cannot pass because the route stopped reading the config.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from pathlib import Path
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from blockbuster import BlockingError
from fastapi import FastAPI
from app.gateway.routers import auth as auth_router
from deerflow.config.app_config import get_app_config, reset_app_config
from deerflow.config.extensions_config import reset_extensions_config
pytestmark = pytest.mark.asyncio
_CONFIG_YAML = """sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
auth:
local:
allow_registration: false
oidc:
enabled: true
frontend_base_url: https://deerflow.example.com
providers:
corp:
display_name: Corp SSO
issuer: https://sso.example.com
client_id: deerflow
"""
def _write_configs(directory: Path) -> tuple[Path, Path]:
config_path = directory / "config.yaml"
extensions_path = directory / "extensions_config.json"
config_path.write_text(_CONFIG_YAML, encoding="utf-8")
extensions_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
return config_path, extensions_path
@pytest.fixture
def local_provider(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
provider = AsyncMock()
provider.count_admin_users.return_value = 0
monkeypatch.setattr(auth_router, "get_local_provider", lambda: provider)
return provider
@pytest_asyncio.fixture
async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, local_provider: AsyncMock) -> AsyncIterator[httpx.AsyncClient]:
config_path, extensions_path = await asyncio.to_thread(_write_configs, tmp_path)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
monkeypatch.setattr(auth_router, "_SETUP_STATUS_CACHE", {})
monkeypatch.setattr(auth_router, "_SETUP_STATUS_INFLIGHT", {})
reset_app_config()
reset_extensions_config()
# Load once off the loop: each request then only pays the per-call
# revalidation, which is the cost every unauthenticated request carries.
await asyncio.to_thread(get_app_config)
app = FastAPI()
app.include_router(auth_router.router)
transport = httpx.ASGITransport(app=app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://gateway.test") as http_client:
yield http_client
finally:
reset_app_config()
reset_extensions_config()
async def test_register_reads_registration_gate_off_the_loop(client: httpx.AsyncClient, local_provider: AsyncMock) -> None:
response = await client.post("/api/v1/auth/register", json={"email": "new@example.com", "password": "Str0ng!Passw0rd#2026"})
assert response.status_code == 403
assert response.json()["detail"]["code"] == "registration_disabled"
local_provider.create_user.assert_not_called()
async def test_setup_status_reads_registration_gate_off_the_loop(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/auth/setup-status")
assert response.status_code == 200
assert response.json() == {"needs_setup": True, "registration_enabled": False}
async def test_providers_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/auth/providers")
assert response.status_code == 200
assert response.json() == {"providers": [{"id": "corp", "display_name": "Corp SSO", "type": "oidc"}]}
async def test_oauth_login_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
# An unconfigured provider is rejected right after the config read, before
# any discovery request to the IdP.
response = await client.get("/api/v1/auth/oauth/other")
assert response.status_code == 400
assert response.json()["detail"] == "Unknown SSO provider: other"
async def test_oauth_callback_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/auth/callback/corp", params={"error": "access_denied"})
assert response.status_code == 302
assert response.headers["location"].startswith("https://deerflow.example.com/")
@pytest.mark.parametrize("read", [auth_router._local_registration_enabled, get_app_config], ids=["registration", "app-config"])
async def test_blocking_probe_inline_config_read_actually_trips_the_gate(client: httpx.AsyncClient, read) -> None:
"""Meta-check: the same reads inline on the loop are caught by the gate."""
with pytest.raises(BlockingError):
read()