W3-d 收尾:关登录时不发汇总和到期提醒、分享页不下发入口表单、邮件标题去换行;测试互不干扰

- notify/digest:关登录时「派给我的」认不出是谁、访问密钥也不生效,待办汇总和密钥到期提醒一概不发,也不认领当天
- resolve_page 的分享旁路不下发入口的表单(分享链接里不能发起)
- 邮件标题里的换行合成空格:应用名、节点名是人起的,邮件头里有换行就是头注入
- 邮件、汇总的测试前后各清一遍渠道、投递记录和 notify 一组,对外访问地址的缓存跟着清;汇总测试只删自己建的待办。
  全量并行跑时不再被同一进程里别的测试留下的行带偏
This commit is contained in:
ThearchyHelios
2026-10-16 17:11:54 +08:00
parent 1fe3d85773
commit fc9b31ecfe
5 changed files with 67 additions and 9 deletions
+2 -1
View File
@@ -701,7 +701,8 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
# 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了
for source in ctx.sources.values():
await _load(session, source)
for entry in spec.get("entries") or []:
# 分享链接里不能发起:入口的表单不下发
for entry in (spec.get("entries") or []) if share is None else []:
form = resolve_form(spec, entry.get("form"))
if form is not None and entry.get("id"):
ctx.forms[str(entry["id"])] = form
+9 -4
View File
@@ -8,13 +8,15 @@ notify_key_expiring),推送放在后台(service.spawn):接收端挂住
一条(没有待办的人不发);
- 按日期在 settings 表里认领一行(键 STATE_PREFIX + "digest:<日期>",主键冲突就是已经认领过):同一天只发一次,重启了也不
重发。认领之后推送失败不补发——通知本来就是至多一次,投递记录里照实记失败。停机错过的那一天不补;
- 认领行只留最近 KEEP_DAYS 天。
- 认领行只留最近 KEEP_DAYS 天;
- 关登录时不发:「派给我的」要认得出是谁(关登录时人人等同管理员,待办箱里是全部待办),个人渠道本来也只挂在认证过的账号上。
访问密钥快到期(key.expiring):
- 到期前 7 天、1 天各推一次(THRESHOLDS):个人访问密钥推给拥有者的个人渠道,服务账号的推给订阅了的全局渠道;
- 去重靠审计:推之前先记一条 access_key.expiring(data.days 是哪一档),记过的不再推(进程里另记一份,省得每拍查审计);
- 不推的:建的时候离到期已经不到这一档(自己设了短有效期,知道它快到期);轮换下来、24 小时后到期的旧密钥(新的那把已经
在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了)。
在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了);关登录时的一概不推(访问密钥只在开了登录时
生效)。
这两种状态(认领行、自动建过邮件渠道的人)存在 settings 表里,键都以 STATE_PREFIX 开头:它们是通知自己记的账,不是用户设置,
GET /api/settings 不列、PUT 不收(api/settings.py)。
@@ -85,12 +87,13 @@ async def _claim(day: str, now: datetime) -> bool:
async def digest_due(now: datetime | None = None) -> list[str]:
"""到点了就认领今天、给订阅了汇总的人各发一条(后台推)。交回这一拍要发给谁(用户 id),没到点、已经发过是空的。"""
from app.api.settings import NOTIFY_GROUP
from app.auth import current_mode
from app.notify import service
now = now or utcnow()
local = now.astimezone()
day = local.date().isoformat()
if day in _claimed:
if day in _claimed or current_mode() == "off":
return []
async with SessionLocal() as session:
row = await session.get(Setting, NOTIFY_GROUP)
@@ -123,13 +126,15 @@ async def _digest_one(user_id: str) -> None:
async def keys_due(now: datetime | None = None) -> list[str]:
"""访问密钥快到期了的,按档推一次(见模块说明)。交回这一拍推了哪几把(密钥 id)。"""
from app.auth import personal_keys_enabled
from app.auth import current_mode, personal_keys_enabled
from app.auth.audit import record
from app.auth.keys import SERVICE_KIND
from app.db.models import AuditEvent
from app.db.models_identity import AccessKey
from app.notify import service
if current_mode() == "off":
return []
now = now or utcnow()
horizon = now + timedelta(days=max(THRESHOLDS))
async with SessionLocal() as session:
+2 -1
View File
@@ -253,7 +253,8 @@ _FOOTER = "这封邮件由 AgentLab 自动发出,请勿直接回复。不想
def build(cfg: MailConfig, to: list[str], msg: Message, *, unsubscribe: str | None) -> EmailMessage:
"""一封邮件:纯文本一份、HTML 一份(全部转义)。unsubscribe 是「设置 → 通知」的完整地址,没有就不带退订头。"""
em = EmailMessage()
em["Subject"] = f"【AgentLab】{msg.title}"[:200]
# 标题里的换行(应用名、节点名是人起的)合成空格:邮件头里有换行就是头注入,标准库也会直接拒收
em["Subject"] = f"【AgentLab】{' '.join(msg.title.split())}"[:200]
em["From"] = formataddr((cfg.from_name or DEFAULTS["from_name"], cfg.from_addr or ""))
em["To"] = ", ".join(to)
em["Date"] = formatdate(localtime=True)
+37 -3
View File
@@ -51,21 +51,38 @@ async def login_on(received):
"""开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。"""
await auth_env.reset()
digest.forget()
await auth_env.turn_on_local()
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()
yield
await auth_env.turn_on_local()
try:
yield
finally:
await _cleanup()
async def _cleanup() -> None:
await service.drain()
async with SessionLocal() as session:
for model in (NotifyDelivery, NotifyChannel, TaskAssignment, Task):
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()
@@ -151,6 +168,23 @@ async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_
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:
+17
View File
@@ -77,14 +77,23 @@ def smtp(monkeypatch):
@pytest.fixture(autouse=True)
async def clean():
"""前后各清一遍:同一个进程里别的测试留下的渠道、投递记录不算进来。"""
await _clean()
yield
await service.drain()
await _clean()
async def _clean() -> None:
async with SessionLocal() as session:
await session.execute(delete(NotifyDelivery))
await session.execute(delete(NotifyChannel))
await session.execute(delete(Setting).where(Setting.key.in_([mail.GROUP, "notify", service.MAIL_AUTO_KEY])))
await session.execute(delete(User).where(User.username.like("mail-%")))
await session.commit()
from app.core import public_url
public_url.remember(None) # 发信时按库里的对外访问地址对齐了缓存:删了那一行,缓存也清掉
async def configure(**over) -> None:
@@ -100,6 +109,9 @@ async def configure(**over) -> None:
notify = await session.get(Setting, "notify")
if notify is None:
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"}))
else:
# 别的测试留下的 notify 一组:对外访问地址换成这里要的(退订头按它拼)
notify.value = {**(notify.value or {}), "public_base_url": "https://agentlab.example.com"}
await session.commit()
@@ -241,6 +253,11 @@ async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch):
assert out.ok is True and out.error == "已交给邮件服务器,其中 1 个收件地址被拒收"
def test_line_breaks_in_titles_never_reach_the_headers():
em = mail.build(_cfg(), ["ana@example.com"], Message(title="新的待办:审批\r\nBcc: x@example.com"), unsubscribe=None)
assert em["Subject"] == "【AgentLab】新的待办:审批 Bcc: x@example.com" and em["Bcc"] is None
def test_ssl_security_uses_no_starttls(smtp):
cfg = mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 465,
"security": "ssl", "from_addr": "agentlab@example.com"}))