Files
AgentLab/backend/tests/test_action_assertion.py
T

145 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""身份断言的 sub 与 act(D-b;设计文档 A6 第 3 条)。
sub 一律是运行的发起人(Run.started_by):这件事是替他办的;确认这次发送的人放在 act 声明(RFC 8693:
{"act": {"sub": 确认人}})。没有确认人就不写 act。on_behalf 的老写法(字符串,就是发起人)照旧认。
"""
from __future__ import annotations
import asyncio
import json
import uuid
import httpx
import jwt
import pytest
from sqlalchemy import delete, select
from app.api.deps import Principal
from app.auth import signing
from app.capabilities import endpoints as ep
from app.db.base import SessionLocal
from app.db.models import Capability, CustomTool, Endpoint, Run, Task
from app.engine.runner import run_manager
from app.process import tasks
from tests.fakes.fake_erp import PAY, FakeErp, endpoint_row, install
def _claims(token: str, audience: str = "erp") -> dict:
key = jwt.PyJWK(signing.jwks()["keys"][0]).key
return jwt.decode(token, key, algorithms=["EdDSA"], audience=audience, issuer="agentlab")
def test_sub_is_the_initiator_and_act_is_the_confirmer():
both = _claims(signing.assertion("erp", "alice", verified=True, actor="bob"))
assert both["sub"] == "alice" and both["act"] == {"sub": "bob"} and both["verified"] is True
alone = _claims(signing.assertion("erp", "alice", verified=False))
assert alone["sub"] == "alice" and "act" not in alone
service = _claims(signing.assertion("erp", None, verified=False))
assert "sub" not in service and "act" not in service
assert ep.on_behalf_parts("alice") == ("alice", None)
assert ep.on_behalf_parts({"sub": "alice", "act": "bob"}) == ("alice", "bob")
assert ep.on_behalf_parts({"sub": None, "act": None}) == (None, None)
assert ep.on_behalf_parts(None) == (None, None)
async def test_the_platform_executor_signs_both(monkeypatch):
sent: list[httpx.Request] = []
real = httpx.AsyncClient
def client(**kwargs):
return real(transport=httpx.MockTransport(lambda r: (sent.append(r), httpx.Response(201, json={}))[1]),
**kwargs)
monkeypatch.setattr(ep, "_client", client)
name = f"erp_pay_{uuid.uuid4().hex[:6]}"
aud = f"erp-{uuid.uuid4().hex[:6]}"
async with SessionLocal() as s:
endpoint = Endpoint(name=aud, environments={"prod": {"base_url": "http://127.0.0.1:9", "headers": {},
"allow_intranet": True}})
s.add(endpoint)
await s.flush()
s.add(CustomTool(name=name, kind="http", parameters={"type": "object", "properties": {}},
config={"endpoint": endpoint.id, "method": "POST", "path": "/payments"}))
cap = Capability(name=name, effect="write", credential_mode="service_on_behalf")
s.add(cap)
await s.commit()
endpoint_id = endpoint.id
try:
async with SessionLocal() as s:
cap = await s.get(Capability, name)
out = await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:1",
on_behalf={"sub": "alice", "act": "bob"})
assert out.status == 201, out.error
claims = _claims(sent[-1].headers["x-agentlab-assertion"], aud)
assert claims["sub"] == "alice" and claims["act"] == {"sub": "bob"}
await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:2", on_behalf="alice")
claims = _claims(sent[-1].headers["x-agentlab-assertion"], aud)
assert claims["sub"] == "alice" and "act" not in claims, "老写法:字符串就是发起人"
finally:
async with SessionLocal() as s:
await s.execute(delete(CustomTool).where(CustomTool.name == name))
await s.execute(delete(Capability).where(Capability.name == name))
await s.execute(delete(Endpoint).where(Endpoint.id == endpoint_id))
await s.commit()
# --------------------------------------------------------------------------
# 调用工具节点:发起人和确认人分开交给执行器
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses and not run_manager.is_active(run_id):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def test_the_call_tool_node_passes_initiator_and_confirmer(engine_up, monkeypatch):
from app.capabilities import registry
erp = FakeErp()
install(monkeypatch, erp, await endpoint_row(test=True), lookup=False)
seen: list = []
fake = registry.endpoint_request
async def recording(session, capability, args, **kw):
seen.append(kw.get("on_behalf"))
return await fake(session, capability, args, **kw)
monkeypatch.setattr(registry, "endpoint_request", recording)
graph = {"nodes": [node("start", "input", fields=[{"name": "amount"}]),
node("pay", "tool", tool=PAY, args={"amount": "{{ input.amount }}", "card": "6222"}),
node("out", "output", fields=[{"name": "单号", "value": "{{ nodes.pay.receipt.id }}"}])],
"edges": [{"source": "start", "target": "pay"}, {"source": "pay", "target": "out"}]}
run = await run_manager.start(graph=graph, input_payload={"amount": 5}, run_class="formal", started_by="alice")
await _wait(run.id, ("interrupted",))
for _ in range(100):
async with SessionLocal() as session:
task = (await session.execute(select(Task).where(Task.run_id == run.id, Task.kind == "action_confirm",
Task.status == "pending"))).scalars().first()
if task is not None:
break
await asyncio.sleep(0.05)
bob = Principal(username="bob", role="admin", mode="off")
card = (await tasks.detail(task.id, bob))["payload"]
await tasks.respond(task.id, bob, decision="approve", payload_hash=card["payload_hash"])
final = await _wait(run.id, ("succeeded", "failed"))
assert final.status == "succeeded", final.error
assert seen == [{"sub": "alice", "act": "bob"}], json.dumps(seen, ensure_ascii=False)