mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:01:58 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
407 lines
24 KiB
Python
407 lines
24 KiB
Python
"""登记的端点与平台执行器、密钥收口(应用平台 A6 第 2、3、6、7 条)。
|
||
|
||
- 端点的认证头只回掩码,提交掩码值表示保持原值,乐观锁;
|
||
- 只有登记、开了内网访问的端点能连内网,开了也照样拦元数据地址和高危端口;模型给的地址照旧过完整 SSRF 检查;
|
||
- 路径参数逐段编码代入,. 和 .. 不收;只认登记的 base_url;
|
||
- 探索运行走测试环境,没有测试环境时写入演练(不发送);正式运行走生产环境;
|
||
- 带幂等头;service_on_behalf 带 EdDSA 身份断言,公钥在 /api/auth/jwks;
|
||
- 超时 → 结果未知;连接没建立 → 确定的失败;
|
||
- 声明了表格的读取落成查询快照同一个形状的端点快照,台账照 query 处理;
|
||
- 运行里的写入不从自定义工具直接发;工具页试运行、工具库执行写入能力一律演练(不发送,交回将要发送的请求);
|
||
- 试运行、测试连接按 HTTP 状态照实说:2xx 才算成功,能连上但 404 / 401 和连不上分开说;
|
||
- 认证头名字为空、不合法的存不进去(422 说人话),存量的坏头发请求时照实说是哪一项,不是 LocalProtocolError;
|
||
- 自定义工具请求头、MCP env 和请求头:加密存、掩码回、启动时存量明文加密一遍(幂等);MCP http 地址过 SSRF 检查或绑定端点。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import httpx
|
||
import jwt
|
||
import pytest
|
||
from sqlalchemy import delete, select
|
||
|
||
from app.capabilities import endpoints as ep
|
||
from app.capabilities import registry
|
||
from app.core import crypto
|
||
from app.core.artifact_store import load
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Artifact, Capability, CustomTool, Endpoint, McpServer, Run
|
||
from app.engine.evidence import query_entry_fields
|
||
from app.tools.net import UnsafeUrlError, assert_safe_url
|
||
from app.tools.registry import ToolContext, Versions, call_tool
|
||
from tests.fakes import auth_env
|
||
|
||
BASE = "http://127.0.0.1:9"
|
||
|
||
|
||
class Recorder:
|
||
"""假的外部系统:记下收到的每个请求,按路径回预设的响应;raise_ 设了就抛那个网络异常。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.requests: list[httpx.Request] = []
|
||
self.raise_: Exception | None = None
|
||
#: 按路径改回的状态码(测 4xx、404 时用)
|
||
self.status: dict[str, int] = {}
|
||
|
||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||
self.requests.append(request)
|
||
if self.raise_ is not None:
|
||
raise self.raise_
|
||
if (code := self.status.get(request.url.path)) is not None:
|
||
return httpx.Response(code, json={"error": "nope"})
|
||
if request.url.path.startswith("/orders"):
|
||
return httpx.Response(200, json={"items": [{"id": "A1", "amount": 12.5, "phone": "13900000001"},
|
||
{"id": "A2", "amount": 7, "phone": "13900000002"}]})
|
||
return httpx.Response(201, json={"ok": True})
|
||
|
||
|
||
@pytest.fixture
|
||
async def erp(monkeypatch):
|
||
recorder = Recorder()
|
||
real = httpx.AsyncClient
|
||
|
||
def client(**kwargs):
|
||
return real(transport=httpx.MockTransport(recorder), **kwargs)
|
||
|
||
monkeypatch.setattr(ep, "_client", client)
|
||
c = auth_env.client()
|
||
r = await c.post("/api/endpoints", json={"name": "erp", "environments": {
|
||
"prod": {"base_url": BASE, "headers": {"Authorization": "Bearer prod-secret-token"}, "allow_intranet": True}}})
|
||
assert r.status_code == 201, r.text
|
||
endpoint = r.json()
|
||
for body in (
|
||
{"name": "erp_orders", "kind": "http", "parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
|
||
"config": {"endpoint": endpoint["id"], "method": "GET", "path": "/orders", "query": {"q": "{{ q }}"}}},
|
||
{"name": "erp_order", "kind": "http",
|
||
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}},
|
||
"config": {"endpoint": endpoint["id"], "method": "GET", "path": "/orders/{{ order_id }}"}},
|
||
{"name": "erp_pay", "kind": "http",
|
||
"parameters": {"type": "object", "properties": {"amount": {"type": "number"}}},
|
||
"config": {"endpoint": endpoint["id"], "method": "POST", "path": "/payments"}},
|
||
):
|
||
r = await c.post("/api/custom-tools", json=body)
|
||
assert r.status_code == 201, r.text
|
||
try:
|
||
yield {"client": c, "endpoint": endpoint, "recorder": recorder}
|
||
finally:
|
||
async with SessionLocal() as s:
|
||
await s.execute(delete(CustomTool).where(CustomTool.name.in_(["erp_orders", "erp_order", "erp_pay"])))
|
||
await s.execute(delete(Endpoint))
|
||
await s.execute(delete(Capability))
|
||
await s.commit()
|
||
await registry.refresh_cache(s)
|
||
|
||
|
||
async def test_endpoint_headers_are_masked_and_kept(erp):
|
||
c, endpoint = erp["client"], erp["endpoint"]
|
||
shown = endpoint["environments"]["prod"]["headers"]["Authorization"]
|
||
assert "prod-secret-token" not in shown
|
||
r = await c.patch(f"/api/endpoints/{endpoint['id']}", json={"version": 1, "environments": {
|
||
"prod": {"base_url": BASE, "headers": {"Authorization": shown}, "allow_intranet": True},
|
||
"test": {"base_url": "http://127.0.0.1:10", "headers": {}}}})
|
||
assert r.status_code == 200, r.text
|
||
async with SessionLocal() as s:
|
||
row = await s.get(Endpoint, endpoint["id"])
|
||
stored = row.environments["prod"]["headers"]["Authorization"]
|
||
assert stored.startswith("enc::") and crypto.decrypt(stored) == "Bearer prod-secret-token", "提交掩码值保持原值"
|
||
assert (await c.patch(f"/api/endpoints/{endpoint['id']}", json={"version": 1})).status_code == 409
|
||
|
||
|
||
async def test_intranet_only_through_registered_endpoints():
|
||
with pytest.raises(UnsafeUrlError):
|
||
await assert_safe_url("http://127.0.0.1:8080/x")
|
||
assert await assert_safe_url("http://127.0.0.1:8080/x", allow_intranet=True)
|
||
for url in ("http://169.254.169.254/latest/meta-data", "http://100.100.100.200/", "http://127.0.0.1:22/"):
|
||
with pytest.raises(UnsafeUrlError):
|
||
await assert_safe_url(url, allow_intranet=True)
|
||
# 没绑定端点的 http 工具拿模型给的内网地址:照旧拦
|
||
from app.tools.custom import _run_http
|
||
|
||
blocked = await _run_http(CustomTool(name="t", kind="http", config={"url": "http://127.0.0.1:8080/"}), {})
|
||
assert "内网" in blocked["error"]
|
||
|
||
|
||
async def test_tabular_read_becomes_an_evidence_snapshot(erp):
|
||
recorder = erp["recorder"]
|
||
async with SessionLocal() as s:
|
||
cap = await s.get(Capability, "erp_orders")
|
||
version = cap.version
|
||
r = await erp["client"].put("/api/capabilities/erp_orders/governance", json={
|
||
"version": version, "tabular": {"rows": "/items", "columns": {"单号": "/id", "金额": "/amount", "电话": "/phone"}},
|
||
"masked": ["/phone"], "credential_mode": "service_on_behalf"})
|
||
assert r.status_code == 200, r.text
|
||
async with SessionLocal() as s:
|
||
run = Run(workflow_name="t", run_class="formal", started_by="alice")
|
||
s.add(run)
|
||
await s.commit()
|
||
run_id = run.id
|
||
ctx = ToolContext(run_id=run_id, node_id="n1", data_versions=Versions.CURRENT)
|
||
async with SessionLocal() as s:
|
||
out = json.loads(await call_tool("erp_orders", {"q": "x y"}, ctx, session=s))
|
||
assert set(out) == {"artifact", "source", "columns", "rows", "truncated", "request"}
|
||
assert out["columns"] == ["单号", "金额", "电话"] and out["rows"][0] == ["A1", 12.5, "13900000001"]
|
||
fields = query_entry_fields(out)
|
||
assert fields and fields["rows"] == 2 and fields["source"] == "erp"
|
||
assert registry.is_platform_query("erp_orders")
|
||
assert await registry.snapshot_owned(out["artifact"], run_id, "erp_orders")
|
||
assert not await registry.snapshot_owned(out["artifact"], "other-run", "erp_orders")
|
||
snap = load(out["artifact"])
|
||
assert snap["mask_columns"] == ["电话"] and snap["response"]["items"][0]["id"] == "A1"
|
||
async with SessionLocal() as s:
|
||
kinds = set((await s.execute(select(Artifact.kind).where(Artifact.id == out["artifact"]))).scalars())
|
||
assert kinds == {"endpoint_snapshot"}
|
||
|
||
sent = recorder.requests[-1]
|
||
assert str(sent.url) == f"{BASE}/orders?q=x+y" and sent.headers["authorization"] == "Bearer prod-secret-token"
|
||
claims = jwt.decode(sent.headers["x-agentlab-assertion"],
|
||
jwt.PyJWK((await erp["client"].get("/api/auth/jwks")).json()["keys"][0]).key,
|
||
algorithms=["EdDSA"], audience="erp", issuer="agentlab")
|
||
assert claims["sub"] == "alice", "替谁办的写在断言里,正式运行的发起人"
|
||
|
||
|
||
async def test_path_params_are_encoded_segment_by_segment(erp):
|
||
ctx = ToolContext(run_id="playground", node_id="t", data_versions=Versions.CURRENT)
|
||
async with SessionLocal() as s:
|
||
await call_tool("erp_order", {"order_id": "a/b?c#d"}, ctx, session=s)
|
||
assert erp["recorder"].requests[-1].url.raw_path == b"/orders/a%2Fb%3Fc%23d"
|
||
failed = await call_tool("erp_order", {"order_id": ".."}, ctx, session=s)
|
||
assert "不能是" in json.dumps(failed, ensure_ascii=False)
|
||
|
||
|
||
async def test_environments_simulation_idempotency_and_unknown(erp):
|
||
recorder = erp["recorder"]
|
||
async with SessionLocal() as s:
|
||
cap = await s.get(Capability, "erp_pay")
|
||
before = len(recorder.requests)
|
||
dry = await ep.endpoint_request(s, cap, {"amount": 3}, environment="test", idem_key="k:1")
|
||
assert dry.environment == "simulated" and dry.body["simulated"] and len(recorder.requests) == before, \
|
||
"没有测试环境:演练,不发送"
|
||
sent = await ep.endpoint_request(s, cap, {"amount": 3}, environment="prod", idem_key="k:1")
|
||
assert sent.status == 201 and recorder.requests[-1].headers["idempotency-key"] == "k:1"
|
||
assert json.loads(recorder.requests[-1].content) == {"amount": 3}
|
||
recorder.raise_ = httpx.ReadTimeout("slow")
|
||
lost = await ep.endpoint_request(s, cap, {"amount": 3}, environment="prod", idem_key="k:2")
|
||
assert lost.unknown and lost.status is None
|
||
recorder.raise_ = httpx.ConnectError("refused")
|
||
refused = await ep.endpoint_request(s, cap, {"amount": 3}, environment="prod", idem_key="k:3")
|
||
assert not refused.unknown and refused.error, "连接没建立:请求没发出去,是确定的失败"
|
||
endpoint = await s.get(Endpoint, erp["endpoint"]["id"])
|
||
assert ep.pick_environment(endpoint, formal=True, effect="write") == "prod"
|
||
assert ep.pick_environment(endpoint, formal=False, effect="write") == "simulated"
|
||
assert ep.pick_environment(endpoint, formal=False, effect="read") == "prod"
|
||
|
||
|
||
async def test_writes_inside_a_run_go_through_the_action_flow(erp):
|
||
async with SessionLocal() as s:
|
||
run = Run(workflow_name="t", run_class="formal")
|
||
s.add(run)
|
||
await s.commit()
|
||
run_id = run.id
|
||
before = len(erp["recorder"].requests)
|
||
async with SessionLocal() as s:
|
||
out = await call_tool("erp_pay", {"amount": 1}, ToolContext(run_id=run_id, node_id="n",
|
||
data_versions=Versions.CURRENT), session=s)
|
||
assert "动作确认" in json.loads(out)["error"] and len(erp["recorder"].requests) == before
|
||
|
||
|
||
async def test_custom_tool_headers_are_sealed_and_masked():
|
||
c = auth_env.client()
|
||
r = await c.post("/api/custom-tools", json={"name": "crm_api", "kind": "http", "parameters": {},
|
||
"config": {"url": "https://crm.example.com", "headers": {"X-Key": "abcdefgh12345"}}})
|
||
assert r.status_code == 201
|
||
shown = r.json()["config"]["headers"]["X-Key"]
|
||
assert "abcdefgh12345" not in shown
|
||
r = await c.patch(f"/api/custom-tools/{r.json()['id']}", json={**r.json(), "description": "改了说明"})
|
||
assert r.status_code == 200
|
||
async with SessionLocal() as s:
|
||
row = (await s.execute(select(CustomTool).where(CustomTool.name == "crm_api"))).scalar_one()
|
||
assert crypto.decrypt(row.config["headers"]["X-Key"]) == "abcdefgh12345"
|
||
await s.delete(row)
|
||
await s.commit()
|
||
|
||
|
||
async def test_existing_plaintext_is_sealed_once():
|
||
from app.capabilities.secrets import seal_existing
|
||
|
||
async with SessionLocal() as s:
|
||
s.add(CustomTool(name="legacy", kind="http", config={"url": "https://x.example.com", "headers": {"K": "plain"}}))
|
||
s.add(McpServer(name="legacy_mcp", transport="stdio", command="x", env={"TOKEN": "plain-token"}))
|
||
await s.commit()
|
||
try:
|
||
assert await seal_existing() == 2
|
||
assert await seal_existing() == 0, "幂等"
|
||
async with SessionLocal() as s:
|
||
tool = (await s.execute(select(CustomTool).where(CustomTool.name == "legacy"))).scalar_one()
|
||
server = (await s.execute(select(McpServer).where(McpServer.name == "legacy_mcp"))).scalar_one()
|
||
assert tool.config["headers"]["K"].startswith("enc::") and crypto.decrypt(server.env["TOKEN"]) == "plain-token"
|
||
finally:
|
||
async with SessionLocal() as s:
|
||
await s.execute(delete(CustomTool).where(CustomTool.name == "legacy"))
|
||
await s.execute(delete(McpServer).where(McpServer.name == "legacy_mcp"))
|
||
await s.commit()
|
||
|
||
|
||
async def test_mcp_http_address_and_secrets(erp):
|
||
c = auth_env.client()
|
||
r = await c.post("/api/mcp/servers", json={"name": "inner", "transport": "http", "url": "http://127.0.0.1:9100/mcp"})
|
||
assert r.status_code == 422, "内网地址不经登记的端点不放行"
|
||
r = await c.post("/api/mcp/servers", json={"name": "inner", "transport": "http", "url": "/mcp",
|
||
"endpoint_id": erp["endpoint"]["id"], "headers": {"X-Token": "tok-123456789"}})
|
||
assert r.status_code == 201, r.text
|
||
row = r.json()
|
||
assert "tok-123456789" not in row["headers"]["X-Token"]
|
||
async with SessionLocal() as s:
|
||
server = await s.get(McpServer, row["id"])
|
||
from app.tools.mcp_manager import check_url
|
||
|
||
config = await check_url(server)
|
||
assert config["url"] == f"{BASE}/mcp"
|
||
assert config["headers"] == {"Authorization": "Bearer prod-secret-token", "X-Token": "tok-123456789"}
|
||
await c.delete(f"/api/mcp/servers/{row['id']}")
|
||
|
||
|
||
def test_run_compare_reads_endpoint_requests():
|
||
from app.engine.compare import _compare_request, _Query
|
||
|
||
def q(source, path, query):
|
||
snap = {"source": source, "columns": [], "rows": [], "request": {"method": "GET", "path": path, "query": query}}
|
||
return _Query(artifact="a", node_id="n", tool="erp_orders", ordinal=1, call_id=None, sql=None, snap=snap,
|
||
problem=None, source=source, inputs=None)
|
||
|
||
assert _compare_request(q("erp", "/orders", {"d": "1"}), q("erp", "/orders", {"d": "1"})).structure == "same"
|
||
assert _compare_request(q("erp", "/orders", {"d": "1"}), q("erp", "/orders", {"d": "2"})).structure == "params_only"
|
||
assert _compare_request(q("erp", "/orders", {}), q("erp", "/refunds", {})).structure == "changed"
|
||
|
||
|
||
async def test_evidence_panel_masks_an_endpoint_snapshot_by_column():
|
||
"""证据面板认端点快照:按列名遮(落快照时记下的加能力现在的遮罩字段),不按数据源找、不把整张表当成「数据源不在了」
|
||
遮掉。能力登记里查不到的只按快照记下的遮。"""
|
||
from app.api.evidence import _mask_sets, _Masks
|
||
|
||
snap = {"capability": "erp_orders", "source": "erp", "columns": ["单号", "电话", "金额"],
|
||
"rows": [["A1", "13800000000", 12]], "mask_columns": ["电话"]}
|
||
sets = await _mask_sets(snap, "erp", _Masks())
|
||
assert sets.hidden == {"电话"} and sets.sure == {"电话"} and not sets.gone
|
||
|
||
|
||
async def test_header_names_are_checked_when_saved_and_when_sent(erp):
|
||
c, endpoint = erp["client"], erp["endpoint"]
|
||
for headers, words in (({"": ""}, "名称不能为空"), ({"X Token": "1"}, "不合法"), ({"X-Token": "第一\n第二"}, "不能换行")):
|
||
r = await c.patch(f"/api/endpoints/{endpoint['id']}", json={"version": 1, "environments": {
|
||
"prod": {"base_url": BASE, "headers": headers, "allow_intranet": True}}})
|
||
assert r.status_code == 422 and "生产环境的认证头有误" in r.json()["detail"] and words in r.json()["detail"], r.text
|
||
r = await c.post("/api/endpoints", json={"name": "erp2", "idempotency_header": "Idem Key", "environments": {
|
||
"prod": {"base_url": BASE}}})
|
||
assert r.status_code == 422 and "幂等请求头" in r.json()["detail"]
|
||
# 加校验之前存进去的 {"": ""}:发请求、测连接都照实说是认证头的事,请求没有发出,也不当成结果未知
|
||
async with SessionLocal() as s:
|
||
row = await s.get(Endpoint, endpoint["id"])
|
||
row.environments = {"prod": {**row.environments["prod"], "headers": {"": ""}}}
|
||
await s.commit()
|
||
before = len(erp["recorder"].requests)
|
||
cap = await s.get(Capability, "erp_pay")
|
||
result = await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:9")
|
||
assert not result.unknown and "认证头有误" in result.error and "名称不能为空" in result.error
|
||
probe = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json()
|
||
assert probe["ok"] is False and probe["reachable"] is False and "认证头有误" in probe["message"]
|
||
assert len(erp["recorder"].requests) == before, "坏头的请求一个都没发"
|
||
|
||
|
||
async def test_probe_tells_reachable_apart_from_ok(erp):
|
||
c, endpoint, recorder = erp["client"], erp["endpoint"], erp["recorder"]
|
||
ok = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json()
|
||
assert ok["ok"] is True and ok["status"] == 201 and "连接成功" in ok["message"]
|
||
recorder.status["/"] = 404
|
||
missing = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json()
|
||
assert (missing["ok"], missing["reachable"], missing["status"]) == (False, True, 404)
|
||
assert "能连上" in missing["message"] and "404" in missing["message"] and "成功" not in missing["message"], \
|
||
"能连上但路径不存在,不能说成连接成功"
|
||
recorder.status["/"] = 401
|
||
denied = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json()
|
||
assert denied["ok"] is False and "认证" in denied["message"]
|
||
recorder.raise_ = httpx.ConnectError("refused")
|
||
down = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json()
|
||
assert (down["ok"], down["reachable"]) == (False, False) and "无法连接" in down["message"]
|
||
|
||
|
||
async def test_trying_a_write_capability_rehearses_instead_of_sending(erp):
|
||
c, endpoint, recorder = erp["client"], erp["endpoint"], erp["recorder"]
|
||
tool = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_pay")
|
||
before = len(recorder.requests)
|
||
trial = (await c.post(f"/api/custom-tools/{tool['id']}/test", json={"args": {"amount": 3}})).json()
|
||
assert trial["ok"] is True and trial["rehearsal"] is True, trial
|
||
req = trial["result"]["request"]
|
||
assert (req["method"], req["url"], req["body"]) == ("POST", f"{BASE}/payments", {"amount": 3})
|
||
assert trial["result"]["environment"] == "prod", "端点没有测试环境:写正式运行要发往的生产环境"
|
||
assert req["headers"] == ["Authorization", "Idempotency-Key"], "只交回请求头的名字"
|
||
assert "prod-secret-token" not in json.dumps(trial, ensure_ascii=False)
|
||
draft = (await c.post("/api/custom-tools/test", json={**tool, "args": {"amount": 3}})).json()
|
||
assert draft["rehearsal"] is True, "没保存的草稿同样只演练"
|
||
# 工具库执行:同样演练,没有副作用也就不先要确认
|
||
run = await c.post("/api/tools/erp_pay/run", json={"args": {"amount": 3}})
|
||
assert run.status_code == 200 and run.json()["rehearsal"] is True and run.json()["result"]["request"]["method"] == "POST"
|
||
assert len(recorder.requests) == before, "写入能力在工具页一个请求都没发"
|
||
# 读取照旧真的发,对方回 4xx 时照实说失败和状态码
|
||
recorder.status["/orders"] = 400
|
||
read = (await c.post("/api/tools/erp_orders/run?confirm=true", json={"args": {"q": "x"}})).json()
|
||
assert read["ok"] is False and "HTTP 400" in read["error"] and "nope" in read["detail"]
|
||
orders = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_orders")
|
||
tried = (await c.post(f"/api/custom-tools/{orders['id']}/test", json={"args": {"q": "x"}})).json()
|
||
assert tried["ok"] is False and "HTTP 400" in tried["error"] and not tried.get("rehearsal")
|
||
assert len(recorder.requests) == before + 2
|
||
|
||
|
||
async def test_a_draft_trial_sends_the_draft_not_the_saved_tool(erp):
|
||
"""工具页试运行的草稿按草稿的配置拼请求,读写按已保存的治理和草稿的方法里更严的一个认。以前按名字找已保存的那一版:
|
||
把已保存的 GET /orders 改成草稿 POST /payments 去试,真发出去的是 GET /orders,结果写「成功」。"""
|
||
c, recorder = erp["client"], erp["recorder"]
|
||
orders = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_orders")
|
||
before = len(recorder.requests)
|
||
post = {**orders, "config": {**orders["config"], "method": "POST", "path": "/payments", "query": {}}}
|
||
trial = (await c.post("/api/custom-tools/test", json={**post, "args": {"q": "x"}})).json()
|
||
assert trial["ok"] is True and trial["rehearsal"] is True, trial
|
||
req = trial["result"]["request"]
|
||
assert (req["method"], req["url"]) == ("POST", f"{BASE}/payments"), req
|
||
assert len(recorder.requests) == before, "草稿改成了写入:已保存的那一版是只读也只演练,一个请求都不发"
|
||
|
||
# 草稿只改了路径和查询参数(还是读取):真的发,发的是草稿的
|
||
moved = {**orders, "config": {**orders["config"], "path": "/orders/open", "query": {"status": "{{ q }}"}}}
|
||
read = (await c.post("/api/custom-tools/test", json={**moved, "args": {"q": "x"}})).json()
|
||
assert read["ok"] is True and not read.get("rehearsal"), read
|
||
[sent] = recorder.requests[before:]
|
||
assert (sent.method, sent.url.path, dict(sent.url.params)) == ("GET", "/orders/open", {"status": "x"})
|
||
|
||
# 还没保存过的绑定端点的读取:同样按草稿发(以前按名字找不到已保存的工具,报没有绑定端点)
|
||
fresh = (await c.post("/api/custom-tools/test", json={**moved, "name": "erp_orders_new", "args": {"q": "y"}})).json()
|
||
assert fresh["ok"] is True, fresh
|
||
assert recorder.requests[-1].url.path == "/orders/open" and len(recorder.requests) == before + 2
|
||
|
||
# 保存过的那一行和登记对得上:照治理的走(管理员把 POST 的查询接口治理成只读,是他的决定)
|
||
async with SessionLocal() as s:
|
||
from app.tools.custom import _effect_of
|
||
|
||
pay_cap = await s.get(Capability, "erp_pay")
|
||
pay = (await s.execute(select(CustomTool).where(CustomTool.name == "erp_pay"))).scalar_one()
|
||
pay_cap.effect = "read"
|
||
assert _effect_of(pay, pay_cap) == "read"
|
||
draft = CustomTool(name="erp_pay", kind="http", config={**pay.config, "method": "GET"})
|
||
assert _effect_of(draft, pay_cap) == "read"
|
||
pay_cap.effect = None
|
||
assert _effect_of(draft, pay_cap) == "write", "草稿改成了 GET:已保存的那一版是写入,取更严的"
|
||
await s.rollback()
|
||
|
||
|
||
async def test_custom_tool_header_names_are_checked():
|
||
c = auth_env.client()
|
||
r = await c.post("/api/custom-tools", json={"name": "bad_headers", "kind": "http", "parameters": {},
|
||
"config": {"url": "https://crm.example.com", "headers": {"": "x"}}})
|
||
assert r.status_code == 422 and "名称不能为空" in r.json()["detail"]
|
||
r = await c.post("/api/custom-tools", json={"name": "bad_headers", "kind": "http", "parameters": {},
|
||
"config": {"url": "https://crm.example.com",
|
||
"headers": {"Authorization": "Bearer {{ 令牌 }}"}}})
|
||
assert r.status_code == 201, "值里的模板不查"
|
||
async with SessionLocal() as s:
|
||
await s.execute(delete(CustomTool).where(CustomTool.name == "bad_headers"))
|
||
await s.commit()
|