fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
171 lines
6.0 KiB
Python
171 lines
6.0 KiB
Python
"""Smoke tests for application/asgi.py.
|
|
|
|
The goal isn't to re-test Flask or FastMCP internals — it's to catch
|
|
regressions in the wiring: mounts resolve, CORS headers emit, lifespan
|
|
runs (without it, the /mcp session manager raises "Task group is not
|
|
initialized"), routing to ``/`` vs ``/mcp`` doesn't cross paths.
|
|
|
|
Uses ``starlette.testclient.TestClient`` because it boots the ASGI app
|
|
end-to-end and handles the lifespan protocol automatically — ``httpx``
|
|
alone does not run lifespan events, which would mask the exact kind of
|
|
misconfiguration this test suite exists to catch.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_asgi_app_imports():
|
|
from application.asgi import asgi_app
|
|
|
|
assert asgi_app is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_flask_route_served_through_starlette_mount():
|
|
"""GET /api/health should reach the Flask app via a2wsgi and return 200."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.get("/api/health")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"status": "ok"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_mcp_endpoint_mounted_and_lifespan_runs():
|
|
"""/mcp must be reachable AND the FastMCP session manager must start.
|
|
|
|
Without ``lifespan=mcp_app.lifespan`` on the outer Starlette app,
|
|
every /mcp request raises ``RuntimeError: Task group is not
|
|
initialized``. Hitting the endpoint under a real lifespan-aware
|
|
client catches that.
|
|
"""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
# Minimal MCP initialize request. Doesn't need to succeed — we
|
|
# just need a non-404, non-500-with-RuntimeError response to
|
|
# confirm the mount + lifespan are both wired.
|
|
r = client.post(
|
|
"/mcp/",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
},
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "pytest", "version": "0"},
|
|
},
|
|
},
|
|
)
|
|
assert r.status_code != 404, f"/mcp mount unreachable: {r.status_code}"
|
|
# A successful initialize returns 200 with a Mcp-Session-Id header.
|
|
assert r.status_code == 200
|
|
assert "mcp-session-id" in {k.lower() for k in r.headers.keys()}
|
|
assert r.headers.get("access-control-expose-headers") == "Mcp-Session-Id"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_headers_on_flask_route():
|
|
"""CORS middleware should emit allow-origin on actual (non-preflight) requests.
|
|
|
|
``allow_origins=["*"]`` → header value is literal ``*`` (not an echo).
|
|
"""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.get("/api/health", headers={"Origin": "http://example.com"})
|
|
assert r.status_code == 200
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_on_flask_route():
|
|
"""OPTIONS preflight on a Flask route should be handled by Starlette CORSMiddleware."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/api/health",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Headers": "Content-Type",
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
assert "GET" in r.headers.get("access-control-allow-methods", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_allows_patch():
|
|
"""PATCH must be in Access-Control-Allow-Methods. The frontend's
|
|
apiClient.patch() (used to edit BYOM custom models via PATCH
|
|
/api/user/models/<id>) is otherwise blocked at preflight by browsers."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/api/health",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "PATCH",
|
|
"Access-Control-Request-Headers": "Content-Type, Authorization",
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert "PATCH" in r.headers.get("access-control-allow-methods", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cors_preflight_on_mcp_route():
|
|
"""Browser clients hitting /mcp should be allowed to send session headers."""
|
|
from starlette.testclient import TestClient
|
|
|
|
from application.asgi import asgi_app
|
|
|
|
with TestClient(asgi_app) as client:
|
|
r = client.options(
|
|
"/mcp/",
|
|
headers={
|
|
"Origin": "http://example.com",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": (
|
|
"Authorization, Content-Type, Mcp-Session-Id"
|
|
),
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204)
|
|
assert r.headers.get("access-control-allow-origin") == "*"
|
|
assert "Mcp-Session-Id" in r.headers.get("access-control-allow-headers", "")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_wsgi_threadpool_sized_from_settings():
|
|
"""The Flask thread pool is the app's request-capacity ceiling —
|
|
it must be operator-tunable (prod incident 2026-07-05→07: 32 slots
|
|
exhausted by SSE holders starved all other requests)."""
|
|
from application import asgi
|
|
from application.core.settings import settings
|
|
|
|
assert asgi._WSGI_THREADPOOL == int(settings.WSGI_THREADPOOL_WORKERS)
|
|
assert asgi._WSGI_THREADPOOL >= 64
|