104 lines
3.6 KiB
Python
104 lines
3.6 KiB
Python
"""风控 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"
|