Files
AgentLab/backend/tests/test_settings_notify.py
T
ThearchyHelios 86d2840e41 W3-d 邮件渠道、待办汇总、访问密钥到期提醒(后端)
邮件(04 细案 B2,集成-I4 / I5,改写应用平台 Q6):
- notify/mail.py:标准库 smtplib 放进 asyncio.to_thread,单次超时 10 秒;4xx 和连不上按 1/2/4 秒重试,5xx 不重试;
  报错只写主机名和服务器回话,口令一律抹掉;纯文本 + 转义过的 HTML,带 List-Unsubscribe 和 Auto-Submitted;测试可换假的
  SMTP 工厂。模块说明写清改写 Q6 的理由:SMTP 是可选配置、退信不追踪照实说「已交给邮件服务器」
- 通知渠道多一种格式「邮件」(core.CHANNEL_FORMATS;定时任务的 Webhook 只会发 HTTP,FORMATS 不加,免得定时任务能选
  邮件):个人邮件渠道不收地址,只发到名录里的账号邮箱,没有邮箱记一条失败投递并写明去「设置 → 身份」填;全局、应用的填
  固定地址,最多 20 个;邮件服务没启用时不收新的邮件渠道;渠道列表另给 mail(启没启用、本人的账号邮箱),投递记录带格式
- 设置的 mail 一组(只有管理员能改):口令用 core/crypto 加密,读的时候只说配没配;启用时要配全;发件人名称、主机不许
  换行;「发测试邮件」只发到管理员自己的账号邮箱,关登录、没有邮箱时 422 说清怎么办
- mail.default_for_assignees(缺省关):开了之后第一次派到待办的人自动有一个个人邮件渠道(订阅待办打开、动作结果未知、
  审核有结果),本人删了不再建回来;关登录时不建

待办汇总与密钥到期(04 细案 B3):
- notify/digest.py 接进调度循环的步骤列表:每天到 notify.digest_at(缺省 09:00,服务端时区)给订阅了 task.digest 的
  个人渠道发汇总(条数、最早时限、最多 5 条节点名、去待办箱的链接,没有待办不发);按日期在 settings 表里插一行认领,
  重启不重发;认领行和自动建邮件渠道的名单是通知自己记的账,设置接口不列不收
- key.expiring:到期前 7 天、1 天各推一次,个人密钥推本人的个人渠道、服务账号的推全局渠道,推之前记审计、按审计去重;
  轮换下来的旧密钥、撤销了的、建的时候就在这一档里的不推
- 两个事件推送实现了,才在 notify/service.EVENTS 打开它们的订阅范围

设置的 notify 一组加 digest_at;只给一部分时补上存着的另几项;环境变量锁住对外访问地址时只核对那一项,汇总时间照常能改。
apps 一组加 share_links(分享链接的开关,缺省关,下一个提交用)。测试:假 SMTP 工厂下的收件人、失败投递、重试、口令、
设置与权限、自动建渠道;汇总按时、只发一次、重启不重发;密钥两档、谁收、不该推的几种。
2026-10-16 16:21:41 +08:00

101 lines
4.9 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.
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址,和每天的待办汇总几点发
(digest_at,缺省 09:00,04 细案 B3)。
- 默认没填(null),通知里就不附运行链接
- 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收
- 写错整个请求回 422、detail 是中文,一项都不落库
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.api.settings import NOTIFY_GROUP, notify_settings, notify_values
from app.db.base import SessionLocal
from app.db.models import Setting
from app.main import app
@pytest.fixture(autouse=True)
async def clean():
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([NOTIFY_GROUP, "run"])))
await session.commit()
yield
def client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
async def test_default_is_no_link():
async with client() as c:
body = (await c.get("/api/settings")).json()
assert body["notify"] == {"public_base_url": None, "digest_at": "09:00"}
async with SessionLocal() as session:
assert await notify_settings(session) == {"public_base_url": None, "digest_at": "09:00"}
@pytest.mark.parametrize("given, stored", [
("https://agentlab.example.com/", "https://agentlab.example.com"),
(" http://10.20.30.40:8080 ", "http://10.20.30.40:8080"),
("https://intra.example.com/agentlab/", "https://intra.example.com/agentlab"),
("", None),
(None, None),
])
async def test_saved_and_normalized(given, stored):
async with client() as c:
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}})
assert put.status_code == 200, put.text
assert put.json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
async with SessionLocal() as session:
assert await notify_settings(session) == {"public_base_url": stored, "digest_at": "09:00"}
@pytest.mark.parametrize("value, fragment", [
({"public_base_url": "agentlab.example.com"}, "http:// 或 https://"),
({"public_base_url": "ftp://files.example.com"}, "http:// 或 https://"),
({"public_base_url": "https://"}, "http:// 或 https://"),
({"public_base_url": 8080}, "http:// 或 https://"),
({"public_base_url": "https://agentlab.example.com/?from=bot"}, "? 或 #"),
({"public_url": "https://agentlab.example.com"}, "不支持「public_url」"),
("https://agentlab.example.com", "格式有误"),
])
async def test_invalid_values_are_refused_in_chinese(value, fragment):
async with client() as c:
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: value, "run": {"stream_tokens": False}}})
assert put.status_code == 422
assert fragment in put.json()["detail"]
assert "对外访问地址" in put.json()["detail"] or "通知" in put.json()["detail"]
# 先查后写:同一请求里别的组也没有落库
assert (await c.get("/api/settings")).json()["run"]["stream_tokens"] is True
def test_broken_stored_value_reads_as_unset():
assert notify_values({"public_base_url": "不是地址", "digest_at": "25:00"}) == {"public_base_url": None,
"digest_at": "09:00"}
assert notify_values("坏掉的") == {"public_base_url": None, "digest_at": "09:00"}
@pytest.mark.parametrize("given, ok", [("08:30", True), ("23:59", True), ("00:00", True), ("24:00", False),
("9:00", False), ("09:60", False), (900, False), ("", False)])
async def test_digest_time_is_hh_mm(given, ok):
async with client() as c:
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": given}}})
if ok:
assert put.status_code == 200, put.text
assert put.json()["notify"]["digest_at"] == given
else:
assert put.status_code == 422
assert "待办汇总的发送时间" in put.json()["detail"]
async def test_changing_digest_time_keeps_the_public_address():
"""只给汇总时间的一组:对外访问地址不被冲成没填(设置页的通知分别保存这两项)。"""
async with client() as c:
await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": "https://agentlab.example.com"}}})
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "17:45"}}})
assert put.json()["notify"] == {"public_base_url": "https://agentlab.example.com", "digest_at": "17:45"}