"""风控 SSE 端点的内容协商与鉴权时序(`docs/25` P3 #24)。 `POST /api/v1/risk/daily-report/stream` 此前既不校验 `Accept`,又把鉴权留在 async generator 内部 —— 后者更隐蔽:`StreamingResponse` 已经返回、响应头已经发出, `403` 只能变成"200 + 半截流"。所以这里同时断言两件事: 1. 显式只接受 `application/json` → `406 SSE_NOT_ACCEPTABLE`,且响应体是统一 JSON 错误; 2. 无权限时即使 `Accept` 也非法,仍先得到 `403 AGENT_PERMISSION_DENIED`(顺序与 `docs/05` §6.4 一致:鉴权先行,防止用状态码差异做探测)。 """ from collections.abc import AsyncIterator from typing import Any import pytest from fastapi.testclient import TestClient from app.api.dependencies.auth import build_request_context from app.api.dependencies.database import get_session from app.core.contracts import RequestContext from app.core.errors import AgentPermissionDeniedError from app.main import create_app STREAM_PATH = "/api/v1/risk/daily-report/stream" async def resolve_context() -> RequestContext: return RequestContext( user_id="1", trace_id="trace-1", permissions=("risk:alert:read",), data_scope="all", ) class StubReportService: """替身:`authorize` 通过、流立即收尾,避免测试连接挂住。""" def __init__(self, session: Any) -> None: self.session = session async def authorize(self, _context: RequestContext) -> None: return None async def stream( self, _context: RequestContext, _now: Any, ) -> AsyncIterator[dict[str, Any]]: yield {"type": "done", "report": {}} class DenyingReportService(StubReportService): async def authorize(self, _context: RequestContext) -> None: raise AgentPermissionDeniedError("缺少 risk:alert:read 权限") def client_with( monkeypatch: pytest.MonkeyPatch, service_class: type[StubReportService], ) -> TestClient: application = create_app() application.dependency_overrides[build_request_context] = resolve_context application.dependency_overrides[get_session] = lambda: None monkeypatch.setattr("app.api.controllers.risk.RiskDailyReportService", service_class) return TestClient(application) @pytest.mark.parametrize("accept", [None, "", "*/*", "text/*", "text/event-stream"]) def test_absent_or_wildcard_accept_is_allowed( monkeypatch: pytest.MonkeyPatch, accept: str | None ) -> None: headers = {} if accept is None else {"Accept": accept} with client_with(monkeypatch, StubReportService) as client: response = client.post(STREAM_PATH, json={}, headers=headers) assert response.status_code == 200 assert response.headers["content-type"].startswith("text/event-stream") def test_json_only_accept_is_rejected_before_streaming( monkeypatch: pytest.MonkeyPatch, ) -> None: with client_with(monkeypatch, StubReportService) as client: response = client.post( STREAM_PATH, json={}, headers={"Accept": "application/json"} ) assert response.status_code == 406 assert response.json()["error"]["code"] == "SSE_NOT_ACCEPTABLE" assert response.headers["content-type"].startswith("application/json") def test_authorization_precedes_accept_negotiation( monkeypatch: pytest.MonkeyPatch, ) -> None: with client_with(monkeypatch, DenyingReportService) as client: response = client.post( STREAM_PATH, json={}, headers={"Accept": "application/json"} ) assert response.status_code == 403 assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"