Files
group_fqcd_jr/tools/acceptance_check.py
T

230 lines
8.1 KiB
Python

"""独立验收脚本:真实 HTTP + 真实 JWT + 真实 MySQL + 显式注册的测试 Agent。
覆盖 P0-1 越权拦截、P0-2 未注册拒绝、P0-3 端到端闭环、P1-3 SSE delta。
生产代码不注册业务 Agent,本脚本按 TODO 阶段 8 的约定显式注入测试注册表。
"""
from __future__ import annotations
import asyncio
import datetime as dt
import uuid
from pathlib import Path
from typing import Any
import httpx
import jwt
from sqlalchemy import delete, select
import app.service.admin_service as admin_service
import app.service.agent_run_application_service as run_service
import app.service.public_platform_service as public_platform_service
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
ResolvedAgentConfig,
)
from app.infrastructure.db import SessionFactory
from app.main import create_app
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
from app.service.agent.base import BaseAgent
from app.service.agent.factory import AgentFactory
from app.worker.runtime import WorkerRuntime
PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8")
CUSTOMER_ID = "9001" # tools/seed_test_rbac.py 造的数据
_results: list[tuple[str, str, str]] = []
def record(name: str, expected: str, actual: str) -> None:
_results.append(("PASS" if expected == actual else "FAIL", name, f"期望 {expected} / 实际 {actual}"))
def token(sub: str) -> str:
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": sub,
"iss": "jr-local",
"aud": "jr-agent-platform",
"exp": now + dt.timedelta(minutes=30),
"nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
PRIVATE_KEY,
algorithm="RS256",
)
class StubGovernance:
async def resolve(self, definition: AgentDefinition, context: RequestContext) -> ResolvedAgentConfig:
del definition, context
return ResolvedAgentConfig(
config_version="acceptance", prompt_version="acceptance", model_endpoint="stub"
)
async def recall(self, context: RequestContext) -> tuple[Any, ...]:
del context
return ()
async def review(
self, result: Any, context: RequestContext, config: Any, memories: Any
) -> Any:
del context, config, memories
return result
class AcceptanceAgent(BaseAgent):
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
del request, context
return CoreResult(text="独立验收通过")
def build_registry() -> AgentFactory:
factory = AgentFactory(StubGovernance())
for agent_type, roles in (
("customer_service", ("customer",)),
("risk", ("risk_operator",)),
):
definition = AgentDefinition(
agent_type=agent_type,
version="acceptance",
allowed_roles=roles,
allowed_portals=("api",),
)
factory.register(definition, lambda _ctx, _def=definition: AcceptanceAgent(_def))
return factory
async def _collect_events(
client: httpx.AsyncClient, run_id: str, auth: dict[str, str]
) -> list[str]:
events: list[str] = []
async with client.stream(
"GET", f"/api/v1/agent-runs/{run_id}/events", headers=auth, timeout=30
) as stream:
async for line in stream.aiter_lines():
if line.startswith("event:"):
events.append(line.split(":", 1)[1].strip())
if events[-1] in {"done", "error"}:
break
return events
async def cleanup(session_id: str, run_id: str | None) -> None:
async with SessionFactory() as session, session.begin():
if run_id:
event_ids = select(DomainEventOutbox.event_id).where(
DomainEventOutbox.aggregate_id == run_id
)
await session.execute(
delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids))
)
await session.execute(
delete(DomainEventOutbox).where(DomainEventOutbox.aggregate_id == run_id)
)
await session.execute(
delete(InteractionAudit).where(InteractionAudit.session_id == session_id)
)
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
await session.execute(
delete(RequestIdempotency).where(RequestIdempotency.session_id == session_id)
)
await session.execute(
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
)
async def main() -> None:
factory = build_registry()
for module in (run_service, public_platform_service, admin_service):
if hasattr(module, "get_agent_factory"):
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
app = create_app()
session_id = f"acceptance-{uuid.uuid4()}"
run_id: str | None = None
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
) as client:
auth = {"Authorization": f"Bearer {token(CUSTOMER_ID)}"}
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "customer_service",
"message": "独立验收消息",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户调用已授权 Agent", "202", str(response.status_code))
if response.status_code == 202:
run_id = response.json().get("run_id")
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "risk",
"message": "越权尝试",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户越权调用 risk Agent", "403", str(response.status_code))
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "no_such_agent",
"message": "x",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("未注册 agent_type", "404", str(response.status_code))
if run_id:
runtime = WorkerRuntime(factory)
await runtime.dispatch_one(run_id=run_id)
# 先建立 SSE 订阅(此时 run 仍为 queued),再执行,才能观察到 delta 分块
subscription = asyncio.create_task(_collect_events(client, run_id, auth))
await asyncio.sleep(1.0)
await runtime.execute(run_id)
events = await subscription
record("SSE 含 delta 事件", "yes", "yes" if "delta" in events else "no")
record("SSE 终止事件", "done", events[-1] if events else "无事件")
response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)
body = response.json()
record("运行终态", "succeeded", str(body.get("status")))
record(
"运行结果内容",
"独立验收通过",
str((body.get("result") or {}).get("content")),
)
finally:
await cleanup(session_id, run_id)
width = max(len(name) for _, name, _ in _results)
print()
for verdict, name, detail in _results:
print(f"[{verdict}] {name.ljust(width)} {detail}")
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
if __name__ == "__main__":
asyncio.run(main())