mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:31:59 +00:00
fc9b31ecfe
- notify/digest:关登录时「派给我的」认不出是谁、访问密钥也不生效,待办汇总和密钥到期提醒一概不发,也不认领当天 - resolve_page 的分享旁路不下发入口的表单(分享链接里不能发起) - 邮件标题里的换行合成空格:应用名、节点名是人起的,邮件头里有换行就是头注入 - 邮件、汇总的测试前后各清一遍渠道、投递记录和 notify 一组,对外访问地址的缓存跟着清;汇总测试只删自己建的待办。 全量并行跑时不再被同一进程里别的测试留下的行带偏
286 lines
13 KiB
Python
286 lines
13 KiB
Python
"""每天的待办汇总、访问密钥快到期(notify/digest.py + notify/service,docs/design/next-round/04-对外集成与触达.md B3)。
|
||
|
||
守的是:
|
||
- 待办汇总到设置的时间(notify.digest_at,按服务端时区)才发,只发给订阅了 task.digest 的个人渠道;内容是条数、最早时限、
|
||
最多 5 条节点在图上的名字(不是按运行数据渲染出来的待办标题)、去待办箱的链接,不放数;
|
||
- 同一天只发一次,重启(进程里记的清掉)也不重发:靠 settings 表里按日期认领的一行;第二天照常发;
|
||
- 没有待办的人不发;
|
||
- 访问密钥到期前 7 天、1 天各推一次,不多推;个人密钥推本人的个人渠道,服务账号的推全局渠道;每次推都记审计;
|
||
- 轮换下来的旧密钥、撤销了的、建的时候就已经在这一档里的(自己设了短有效期),不推。
|
||
|
||
都用 Webhook 渠道和 httpx.MockTransport 接收;人名、应用名是虚构的。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import httpx
|
||
import pytest
|
||
from sqlalchemy import delete, select
|
||
|
||
from app.core.crypto import encrypt
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import App, AuditEvent, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
|
||
from app.notify import core, digest, service
|
||
from tests.fakes import auth_env
|
||
|
||
URL = "https://hooks.example.com/agentlab/digest-91c2"
|
||
|
||
|
||
@pytest.fixture
|
||
def received(monkeypatch):
|
||
got: list[dict] = []
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
got.append(json.loads(request.content))
|
||
return httpx.Response(200, json={"ok": True})
|
||
|
||
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
|
||
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
|
||
|
||
async def public(url: str) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(service, "personal_url_problem", public)
|
||
return got
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def login_on(received):
|
||
"""开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。"""
|
||
await auth_env.reset()
|
||
digest.forget()
|
||
async with SessionLocal() as session:
|
||
# 别的测试可能留下了 notify 一组、渠道和投递记录:整组换成这里要的,渠道和记录先清掉
|
||
await session.execute(delete(Setting).where(Setting.key == "notify"))
|
||
await session.execute(delete(NotifyDelivery))
|
||
await session.execute(delete(NotifyChannel))
|
||
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com",
|
||
"digest_at": "09:00"}))
|
||
await session.commit()
|
||
await auth_env.turn_on_local()
|
||
try:
|
||
yield
|
||
finally:
|
||
await _cleanup()
|
||
|
||
|
||
async def _cleanup() -> None:
|
||
await service.drain()
|
||
async with SessionLocal() as session:
|
||
mine = select(Run.id).where(Run.workflow_name == "汇总测试")
|
||
tasks = select(Task.id).where(Task.run_id.in_(mine))
|
||
await session.execute(delete(TaskAssignment).where(TaskAssignment.task_id.in_(tasks)))
|
||
await session.execute(delete(Task).where(Task.run_id.in_(mine)))
|
||
for model in (NotifyDelivery, NotifyChannel):
|
||
await session.execute(delete(model))
|
||
await session.execute(delete(Run).where(Run.workflow_name == "汇总测试"))
|
||
await session.execute(delete(App).where(App.name == "报销审批"))
|
||
await session.execute(delete(Setting).where(Setting.key.like(digest.STATE_PREFIX + "%")))
|
||
await session.execute(delete(Setting).where(Setting.key == "notify"))
|
||
await session.commit()
|
||
from app.core import public_url
|
||
|
||
public_url.remember(None)
|
||
digest.forget()
|
||
await auth_env.reset()
|
||
|
||
|
||
def local(hour: int, minute: int, *, day: int = 16) -> datetime:
|
||
"""服务端所在时区的某个时刻(汇总按服务端时区算)→ UTC。"""
|
||
return datetime(2026, 10, day, hour, minute).astimezone().astimezone(timezone.utc)
|
||
|
||
|
||
async def personal(user: User, events: list[str]) -> NotifyChannel:
|
||
async with SessionLocal() as session:
|
||
row = NotifyChannel(name=f"个人-{user.username}", scope="user", owner_id=user.id, format="generic",
|
||
url=encrypt(URL), events=events)
|
||
session.add(row)
|
||
await session.commit()
|
||
return row
|
||
|
||
|
||
async def tasks_for(username: str, n: int, *, label: str = "主管审批") -> None:
|
||
graph = {"nodes": [{"id": "review", "type": "human", "data": {"label": label}}], "edges": []}
|
||
async with SessionLocal() as session:
|
||
app = App(name="报销审批", published_version=1)
|
||
session.add(app)
|
||
run = Run(workflow_name="汇总测试", status="interrupted", graph=graph, input={"amount": 45678})
|
||
session.add(run)
|
||
await session.flush()
|
||
for i in range(n):
|
||
task = Task(run_id=run.id, node_id="review", app_id=app.id, kind="approve",
|
||
title=f"审批 45678 元的报销 #{i}", due_at=(utcnow() + timedelta(hours=i + 1)).replace(tzinfo=None))
|
||
session.add(task)
|
||
await session.flush()
|
||
session.add(TaskAssignment(task_id=task.id, principal_kind="user", principal=username))
|
||
await session.commit()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 待办汇总
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_digest_is_sent_once_a_day_at_the_set_time(received):
|
||
ana = await auth_env.make_user("ana", "user")
|
||
bo = await auth_env.make_user("bo", "user")
|
||
await personal(ana, ["task.digest"])
|
||
await personal(bo, ["task.opened"]) # 没订阅汇总
|
||
await tasks_for("ana", 7)
|
||
await tasks_for("bo", 2)
|
||
|
||
assert await digest.digest_due(local(8, 59)) == [], "还没到点"
|
||
assert await digest.digest_due(local(9, 0)) == [ana.id]
|
||
await service.drain()
|
||
[body] = received
|
||
assert body["event"] == "task.digest" and body["title"] == "你有 7 条待办"
|
||
text = body["text"]
|
||
assert "条数:7 条" in text and "最早时限" in text
|
||
assert text.count("「主管审批」") == 5, "最多列 5 条,用节点在图上的名字"
|
||
assert "另有:2 条" in text and "报销审批" in text
|
||
assert "45678" not in json.dumps(body, ensure_ascii=False), "不放按运行数据渲染出来的标题、不放数"
|
||
assert body["links"] == [{"label": "去待办箱", "url": "https://agentlab.example.com/apps?tab=inbox"}]
|
||
|
||
assert await digest.digest_due(local(9, 15)) == [], "同一天只发一次"
|
||
digest.forget() # 重启:进程里记的没了
|
||
assert await digest.digest_due(local(10, 0)) == [], "重启了也不重发:认领在 settings 表里"
|
||
await service.drain()
|
||
assert len(received) == 1
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.event == "task.digest"))).scalars()
|
||
assert [(r.subject_kind, r.subject_id, r.status) for r in rows] == [("digest", ana.id, "ok")]
|
||
|
||
assert await digest.digest_due(local(9, 1, day=17)) == [ana.id], "第二天照常"
|
||
|
||
|
||
async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_pending(received):
|
||
cy = await auth_env.make_user("cy", "user")
|
||
await personal(cy, ["task.digest"])
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Setting, "notify")
|
||
row.value = {**row.value, "digest_at": "17:30"}
|
||
await session.commit()
|
||
assert await digest.digest_due(local(9, 0)) == []
|
||
assert await digest.digest_due(local(17, 30)) == [cy.id]
|
||
await service.drain()
|
||
assert received == [], "没有待办不发「0 条」"
|
||
|
||
|
||
async def test_nothing_is_sent_when_login_is_off(received):
|
||
"""关登录时「派给我的」认不出是谁(人人等同管理员),访问密钥也不生效:汇总、到期提醒都不发,也不认领今天。"""
|
||
ana = await auth_env.make_user("ana", "user")
|
||
await personal(ana, ["task.digest"])
|
||
await tasks_for("ana", 2)
|
||
svc = await auth_env.make_service("svc-off")
|
||
key, _ = await auth_env.make_key(svc, days=3)
|
||
await _age(key, 30)
|
||
from app.auth import remember_settings
|
||
|
||
remember_settings({})
|
||
assert await digest.digest_due(local(9, 0)) == []
|
||
assert await digest.keys_due(utcnow()) == []
|
||
async with SessionLocal() as session:
|
||
assert await session.get(Setting, digest.DIGEST_CLAIM + local(9, 0).astimezone().date().isoformat()) is None
|
||
|
||
|
||
async def test_claim_rows_are_not_listed_as_settings(received):
|
||
await digest.digest_due(local(9, 0))
|
||
async with auth_env.client() as c:
|
||
root = await auth_env.login("root")
|
||
body = (await root.get("/api/settings")).json()
|
||
assert not [k for k in body if k.startswith(digest.STATE_PREFIX)]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 访问密钥快到期
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def _expiring_audits(key_id: str) -> list[int]:
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(AuditEvent).where(AuditEvent.action == "access_key.expiring",
|
||
AuditEvent.subject_id == key_id))).scalars()
|
||
return sorted(r.data["days"] for r in rows)
|
||
|
||
|
||
async def _age(key, days: float) -> None:
|
||
from app.db.models_identity import AccessKey
|
||
|
||
async with SessionLocal() as session:
|
||
row = await session.get(AccessKey, key.id)
|
||
row.created_at = utcnow() - timedelta(days=days)
|
||
await session.commit()
|
||
|
||
|
||
async def test_personal_key_reminds_its_owner_at_7_and_1_days(received):
|
||
await auth_env.personal_keys(True)
|
||
dee = await auth_env.make_user("dee", "user")
|
||
mine = await personal(dee, ["key.expiring"])
|
||
async with SessionLocal() as session:
|
||
session.add(NotifyChannel(name="全局", scope="global", format="generic", url=encrypt(URL),
|
||
events=["key.expiring"]))
|
||
await session.commit()
|
||
key, secret = await auth_env.make_key(dee, days=30, name="周报脚本")
|
||
await _age(key, 60)
|
||
now = utcnow()
|
||
assert await digest.keys_due(now) == [], "离到期还有 30 天"
|
||
assert await digest.keys_due(now + timedelta(days=23, hours=1)) == [key.id]
|
||
await service.drain()
|
||
[body] = received
|
||
assert body["event"] == "key.expiring" and "周报脚本" in body["title"] and "7 天后" in body["title"]
|
||
assert secret not in json.dumps(body) and key.prefix in body["text"]
|
||
assert body["links"] == [{"label": "去轮换", "url": "https://agentlab.example.com/settings/keys"}]
|
||
async with SessionLocal() as session:
|
||
to = (await session.execute(select(NotifyDelivery.channel_id))).scalars().all()
|
||
assert to == [mine.id], "个人密钥只推本人,不推全局渠道"
|
||
assert await digest.keys_due(now + timedelta(days=24)) == [], "这一档推过了"
|
||
digest.forget()
|
||
assert await digest.keys_due(now + timedelta(days=25)) == [], "重启后按审计认得出推过了"
|
||
assert await digest.keys_due(now + timedelta(days=29, hours=1)) == [key.id], "到期前 1 天再推一次"
|
||
assert await digest.keys_due(now + timedelta(days=29, hours=12)) == []
|
||
assert await _expiring_audits(key.id) == [1, 7]
|
||
await service.drain()
|
||
assert len(received) == 2 and "1 天内" in received[1]["title"]
|
||
|
||
|
||
async def test_service_account_keys_remind_global_channels(received):
|
||
svc = await auth_env.make_service("svc-portal")
|
||
async with SessionLocal() as session:
|
||
glob = NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL), events=["key.expiring"])
|
||
session.add(glob)
|
||
await session.commit()
|
||
key, _ = await auth_env.make_key(svc, days=5, name="门户拉数")
|
||
await _age(key, 30)
|
||
assert await digest.keys_due(utcnow()) == [key.id]
|
||
await service.drain()
|
||
[body] = received
|
||
assert "服务账号 svc-portal" in body["text"]
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(NotifyDelivery.channel_id))).scalars().all() == [glob.id]
|
||
|
||
|
||
async def test_keys_that_should_not_remind(received):
|
||
from app.db.models_identity import AccessKey
|
||
|
||
svc = await auth_env.make_service("svc-bi")
|
||
async with SessionLocal() as session:
|
||
session.add(NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL),
|
||
events=["key.expiring"]))
|
||
await session.commit()
|
||
short, _ = await auth_env.make_key(svc, days=3, name="临时") # 建的时候就只有 3 天:不推 7 天那一档
|
||
old, _ = await auth_env.make_key(svc, days=0.5, name="轮换下来的")
|
||
await _age(old, 90)
|
||
new, _ = await auth_env.make_key(svc, days=90, name="轮换上去的")
|
||
revoked, _ = await auth_env.make_key(svc, days=2, name="撤销了的")
|
||
await _age(revoked, 30)
|
||
async with SessionLocal() as session:
|
||
(await session.get(AccessKey, new.id)).rotated_from = old.id
|
||
(await session.get(AccessKey, revoked.id)).revoked_at = utcnow()
|
||
await session.commit()
|
||
assert await digest.keys_due(utcnow()) == []
|
||
assert await digest.keys_due(utcnow() + timedelta(days=2, hours=1)) == [short.id], "1 天那一档照推"
|
||
await service.drain()
|
||
assert len(received) == 1
|