Files
AgentLab/backend/tests/test_endpoints.py
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

407 lines
24 KiB
Python
Raw Permalink 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.
"""登记的端点与平台执行器、密钥收口(应用平台 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()