deer-flow/backend/tests/test_openapi_operation_ids.py
Sunshine a956bbc030
fix(runs): reject cancel actions on GET stream joins (#5092)
* fix(runs): reject cancel actions on GET stream joins

stream_existing_run is registered for both GET and POST, and its
?action=interrupt|rollback branch cancels the run. The CSRF middleware
exempts GET, so a session-authenticated browser could be forced
cross-site (img/script/top-level navigation) into
GET /api/threads/{id}/runs/{run_id}/stream?action=interrupt|rollback —
a state-changing GET that bypasses the CSRF protection guarding the
POST variant. Introduced with the dual registration in #1403.

The handler's docstring already documents cancel-then-stream as
POST-only (the LangGraph SDK's joinStream/useStream stop button uses
POST); enforce it: GET with an action answers 405, action-less GET
joins and POST cancel-then-stream are unchanged.

Regression drives the real router: GET+action is 405 with the run left
running, plain GET join still streams, POST+action still cancels.

* fix(runs): scope the 405 detail to the action requirement

"GET is a read-only stream join" overstates the current main: on a
locally-owned run with the default on_disconnect=cancel, a GET join's
disconnect can still trigger cancellation. That observer-disconnect
vector is closed by #5041; the detail here should only claim what this
guard enforces.

* fix(runs): harden GET stream action rejection

* fix(runs): align stream schema with method contract

* test(runs): pin GET stream action 405 through the production stack

Review follow-up (defence-in-depth): the GET-action suite drove bare
FastAPI() apps, so nothing pinned that a session-authenticated
cross-site GET reaches the route gate at all once CSRF exempts the
safe method. test_pat_auth.py already assembles the production
middleware order (AuthMiddleware inner, CSRFMiddleware outer), so its
mirror app now registers the real _reject_get_stream_action
dependency on a GET join route.

The new case pins the end-to-end premise: an authenticated GET
?action=interrupt is answered 405 + Allow: POST by the production
route dependency, while the same unauthenticated GET dies at
AuthMiddleware's 401 before any route logic runs.

Validation: focused suites (test_pat_auth, test_stream_get_action,
test_csrf_middleware) — 62 passed; ruff check + format clean; the new
case errors on the pre-fix baseline (guard absent), confirming the
pin.
2026-09-01 15:51:37 +08:00

94 lines
4.1 KiB
Python

"""Regression tests for the generated OpenAPI spec.
The Gateway exposes its FastAPI ``app.openapi()`` schema at ``/openapi.json``
and downstream tooling (SDK codegen, schema validators, client generators)
relies on ``operationId`` values being globally unique. FastAPI emits a
``UserWarning`` during spec generation when two routes share the same
``operationId`` — concretely this happens when ``@router.api_route`` registers
one route for multiple HTTP methods, because the auto-generated unique id is
computed from a single method picked out of ``route.methods`` while OpenAPI
generation iterates over every method on that route.
These tests pin that invariant so the warning cannot silently come back.
"""
from __future__ import annotations
import warnings
import pytest
@pytest.fixture(scope="module")
def openapi_spec() -> dict:
"""Build the OpenAPI spec for the Gateway app once per module."""
from app.gateway.app import app
# ``app.openapi()`` caches the result on the FastAPI instance, so reset to
# force a fresh generation pass that triggers any duplicate-id warnings.
app.openapi_schema = None
return app.openapi()
def test_openapi_spec_has_no_duplicate_operation_warnings() -> None:
"""Generating the OpenAPI schema must not emit any ``Duplicate Operation ID`` UserWarning."""
from app.gateway.app import app
app.openapi_schema = None
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
app.openapi()
dup_messages = [str(item.message) for item in caught if "Duplicate Operation ID" in str(item.message)]
assert dup_messages == [], f"OpenAPI generation emitted duplicate operation id warnings: {dup_messages}"
def test_openapi_operation_ids_are_unique(openapi_spec: dict) -> None:
"""Every (path, method) operation in the spec must carry a unique ``operationId``."""
op_id_to_locations: dict[str, list[tuple[str, str]]] = {}
for path, path_item in openapi_spec.get("paths", {}).items():
for method, operation in path_item.items():
if not isinstance(operation, dict):
continue
op_id = operation.get("operationId")
if op_id is None:
continue
op_id_to_locations.setdefault(op_id, []).append((path, method))
duplicates = {op_id: locations for op_id, locations in op_id_to_locations.items() if len(locations) > 1}
assert not duplicates, f"Duplicate operationIds in OpenAPI spec: {duplicates}"
def test_stream_existing_run_exposes_distinct_get_and_post(openapi_spec: dict) -> None:
"""The ``/runs/{run_id}/stream`` endpoint must expose GET and POST as distinct operations.
LangGraph SDK ``joinStream`` uses GET while ``useStream``'s stop button uses POST, so
both methods must remain registered with their own ``operationId``.
"""
path = "/api/threads/{thread_id}/runs/{run_id}/stream"
path_item = openapi_spec["paths"].get(path)
assert path_item is not None, f"Expected {path} to be present in the OpenAPI spec"
assert "get" in path_item, f"Expected GET handler on {path}"
assert "post" in path_item, f"Expected POST handler on {path}"
get_op_id = path_item["get"].get("operationId")
post_op_id = path_item["post"].get("operationId")
assert get_op_id and post_op_id, "Both GET and POST must have operationIds"
assert get_op_id != post_op_id, f"GET and POST share operationId {get_op_id!r}, which breaks OpenAPI codegen"
def test_stream_existing_run_exposes_method_specific_query_parameters(openapi_spec: dict) -> None:
"""Only POST advertises the cancel-then-stream query contract."""
path = "/api/threads/{thread_id}/runs/{run_id}/stream"
path_item = openapi_spec["paths"][path]
get_parameters = {(parameter["in"], parameter["name"]) for parameter in path_item["get"].get("parameters", [])}
post_parameters = {(parameter["in"], parameter["name"]) for parameter in path_item["post"].get("parameters", [])}
assert ("query", "action") not in get_parameters
assert ("query", "wait") not in get_parameters
assert ("query", "action") in post_parameters
assert ("query", "wait") in post_parameters