Files
AgentLab/backend/tests/test_notify_channels.py
T

191 lines
9.2 KiB
Python

"""通知渠道与投递(app/notify/,设计文档 §2.4 第 7 条)。
发送核心从定时任务的 Webhook 抽出来共用(格式、签名、发送、脱敏),定时任务的老测试一个字不改照样通过;
渠道的地址、密钥加密落库,接口只回主机名;待办派出、应用实例结束、动作结果未知时按应用的订阅推送,带直达链接,
不放报告里的数字。
"""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting
from app.engine.runner import run_manager
from app.notify import core, service
from tests.fixtures.names import letters
URL = "https://hooks.example.com/agentlab/inbox-7f3a9c2e1b"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await service.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
def received(monkeypatch):
got: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
got.append(request)
return httpx.Response(200, json={"ok": True})
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
return got
async def test_channel_crud_hides_the_address_and_secret(client):
resp = await client.post("/api/notify/channels", json={
"name": "审批群", "format": "generic", "url": URL, "secret": "s3cret", "events": ["task.opened"]})
assert resp.status_code == 201, resp.text
row = resp.json()
assert row["url_host"] == "hooks.example.com" and row["signed"] is True
assert URL not in json.dumps(row) and "s3cret" not in json.dumps(row)
async with SessionLocal() as session:
stored = await session.get(NotifyChannel, row["id"])
assert stored.url != URL and stored.secret != "s3cret" # 密文
updated = await client.put(f"/api/notify/channels/{row['id']}", json={
"name": "审批群", "format": "generic", "events": ["task.opened", "action.unknown"]})
assert updated.status_code == 200 and updated.json()["events"] == ["task.opened", "action.unknown"]
assert updated.json()["url_host"] == "hooks.example.com" and updated.json()["signed"] is True # 不传就保持原值
assert row["id"] in [c["id"] for c in (await client.get("/api/notify/channels")).json()["items"]]
toggled = await client.patch(f"/api/notify/channels/{row['id']}", json={"enabled": False})
assert toggled.status_code == 200 and toggled.json()["enabled"] is False
assert toggled.json()["events"] == ["task.opened", "action.unknown"] and toggled.json()["signed"] is True
assert (await client.delete(f"/api/notify/channels/{row['id']}")).status_code == 204
async def test_channel_validation(client):
bad_event = await client.post("/api/notify/channels", json={"name": "x", "url": URL, "events": ["nope"]})
assert bad_event.status_code == 422
wrong_format = await client.post("/api/notify/channels", json={
"name": "x", "format": "generic", "url": "https://open.feishu.cn/open-apis/bot/v2/hook/abcdef123456"})
assert wrong_format.status_code == 422 and "飞书" in wrong_format.json()["detail"]
personal = await client.post("/api/notify/channels", json={"name": "x", "scope": "user", "url": URL})
assert personal.status_code == 422 # 关登录时没有用户,个人渠道无从认起
async def test_personal_channels_cannot_point_at_the_intranet(received):
"""个人渠道权限最低的使用者也能建:地址过 SSRF 检查,保存、发送时各一次;投递记录只看得到自己渠道的。"""
from tests.fakes import auth_env
await auth_env.reset()
try:
await auth_env.turn_on_local()
uma_row = await auth_env.make_user("uma", "user")
uma = await auth_env.login("uma")
for url in ("http://127.0.0.1:8080/hook", "http://169.254.169.254/latest/meta-data", "http://10.0.0.8/x"):
r = await uma.post("/api/notify/channels", json={"name": "我的", "scope": "user", "url": url})
assert r.status_code == 422 and "公网" in r.json()["detail"], (url, r.text)
assert "10.0.0.8" not in r.json()["detail"], "说法不带检查的细节"
async with SessionLocal() as session:
# 存进库之后域名才改指向内网的:发送前再查一次,不发
from app.core.crypto import encrypt
row = NotifyChannel(name="我的", scope="user", owner_id=uma_row.id, url=encrypt("http://127.0.0.1:9/x"),
events=[])
other = NotifyChannel(name="全局", scope="global", url=encrypt(URL), events=[])
session.add_all([row, other])
await session.flush()
session.add(NotifyDelivery(channel_id=other.id, subject_kind="run", subject_id="r", event="run.failed",
status="ok"))
await session.commit()
tested = (await uma.post(f"/api/notify/channels/{row.id}/test")).json()
assert tested["ok"] is False and "公网" in tested["message"] and received == []
assert [d["channel_id"] for d in (await uma.get("/api/notify/deliveries")).json()["items"]] == [row.id]
finally:
async with SessionLocal() as session:
for r in (await session.execute(select(NotifyChannel))).scalars():
await session.delete(r)
await session.commit()
await auth_env.reset()
async def test_test_message_is_signed_and_recorded(client, received):
row = (await client.post("/api/notify/channels", json={
"name": "审批群", "format": "generic", "url": URL, "secret": "s3cret"})).json()
resp = await client.post(f"/api/notify/channels/{row['id']}/test")
assert resp.json()["ok"] is True
[request] = received
from app.process.hooks import verify
verify("s3cret", dict(request.headers), request.content) # 同一套签名
deliveries = (await client.get(f"/api/notify/deliveries?channel_id={row['id']}")).json()["items"]
assert [(d["event"], d["status"]) for d in deliveries] == [("test", "ok")]
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
async def test_task_opened_is_pushed_to_the_app_subscription_without_numbers(received):
async with SessionLocal() as session:
channel = NotifyChannel(name="财务群", format="generic", url=core_encrypt(URL), events=[])
session.add(channel)
app = App(name=f"报销-{letters(6)}", published_version=1)
session.add(app)
await session.flush()
session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={},
spec={"notify": [{"channel": channel.id, "events": ["task.opened"]}]}))
setting = await session.get(Setting, "notify")
if setting is None:
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example"}))
await session.commit()
app_id, channel_id = app.id, channel.id
graph = {"nodes": [node("start", "input", fields=[{"name": "amount"}]),
node("review", "human", mode="approve", title="审批 {{ input.amount }} 元的报销"),
node("out", "output", fields=[])],
"edges": [{"source": "start", "target": "review"}, {"source": "review", "target": "out"}]}
run = await run_manager.start(graph=graph, input_payload={"amount": 45678}, app_id=app_id, app_version=1)
for _ in range(200):
async with SessionLocal() as session:
status = await session.scalar(select(Run.status).where(Run.id == run.id))
if status == "interrupted":
break
await asyncio.sleep(0.05)
await service.drain()
assert len(received) == 1
body = json.loads(received[0].content)
assert body["event"] == "task.opened" and "45678" not in received[0].content.decode()
assert body["title"] == "新的待办:review"
assert body["links"][0]["url"].startswith("https://agentlab.example/apps/inbox/")
async with SessionLocal() as session:
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.channel_id == channel_id)))\
.scalars().all()
assert [(r.event, r.status, r.subject_kind) for r in rows] == [("task.opened", "ok", "task")]
def core_encrypt(value: str) -> str:
from app.core.crypto import encrypt
return encrypt(value)
def test_webhook_module_still_exports_the_moved_names():
"""抽出去的名字照原名再导出:调用方(api/schedules.py 等)和定时任务的测试不用改。"""
from app.scheduler import webhook
for name in ("FORMATS", "FORMAT_LABEL", "check_url", "bot_format", "format_problem", "host_of", "url_hint",
"Outcome", "send", "build_request", "send_test", "notify_due"):
assert hasattr(webhook, name), name
assert webhook.check_url is core.check_url