230 lines
8.1 KiB
Python
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())
|