mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:12:00 +00:00
86d2840e41
邮件(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 工厂下的收件人、失败投递、重试、口令、 设置与权限、自动建渠道;汇总按时、只发一次、重启不重发;密钥两档、谁收、不该推的几种。
173 lines
8.2 KiB
Python
173 lines
8.2 KiB
Python
"""对外访问地址只有一个说法(04 细案 B4,core/public_url.py)。
|
||
|
||
以前有两个:环境变量 AGENTLAB_PUBLIC_URL 只管单点登录的回调和来源核对,设置里 notify 一组的 public_base_url 只管通知里的
|
||
链接——只配了环境变量的部署,通知里不带链接。现在全站经 public_base_url() 取:
|
||
|
||
- 环境变量优先,其次设置,都没有是 None;
|
||
- 设置的值缓存在进程里(中间件按请求核对来源,不能每次查库),改设置的接口写完就刷新,不用重启;
|
||
- 环境变量设了:设置页显示「由环境变量指定」(GET /api/settings 的 public_url 一组),改成别的值回 409、带 code;
|
||
- 通知、定时任务 Webhook 的链接,单点登录的回调地址,非 GET 请求的 Origin 核对都用它。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import delete
|
||
|
||
from app.api.settings import NOTIFY_GROUP
|
||
from app.core import public_url
|
||
from app.core.config import Settings, settings
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Setting
|
||
from app.main import app as main_app
|
||
from tests.fakes import auth_env
|
||
|
||
ENV_URL = "https://agentlab.example.com"
|
||
SET_URL = "https://intra.example.net/agentlab"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def clean(monkeypatch):
|
||
monkeypatch.setattr(settings, "public_url", "")
|
||
async with SessionLocal() as session:
|
||
await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP))
|
||
await session.commit()
|
||
public_url.remember(None)
|
||
yield
|
||
async with SessionLocal() as session:
|
||
await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP))
|
||
await session.commit()
|
||
public_url.remember(None)
|
||
|
||
|
||
def client() -> AsyncClient:
|
||
return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test")
|
||
|
||
|
||
async def put_address(c: AsyncClient, value: str | None):
|
||
return await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": value}}})
|
||
|
||
|
||
# ---------------------------------------------------------------- 取值的先后
|
||
|
||
|
||
def test_env_wins_over_the_setting(monkeypatch):
|
||
public_url.remember(SET_URL)
|
||
assert public_url.public_base_url() == SET_URL and not public_url.env_locked()
|
||
monkeypatch.setattr(settings, "public_url", ENV_URL)
|
||
assert public_url.public_base_url() == ENV_URL and public_url.env_locked()
|
||
public_url.remember(None)
|
||
assert public_url.public_base_url() == ENV_URL, "设置清空了也还是环境变量的"
|
||
monkeypatch.setattr(settings, "public_url", "")
|
||
assert public_url.public_base_url() is None, "两处都没有:None(通知里不附链接,回调按请求的 Host 拼)"
|
||
|
||
|
||
def test_remember_normalizes_and_drops_broken_values():
|
||
public_url.remember(" https://agentlab.example.com/ ")
|
||
assert public_url.public_base_url() == "https://agentlab.example.com"
|
||
for broken in ("agentlab.example.com", "ftp://files.example.com", "https://a.example.com/?x=1", 8080, ""):
|
||
public_url.remember(broken)
|
||
assert public_url.public_base_url() is None, broken
|
||
|
||
|
||
def test_env_value_is_normalized_and_checked_at_startup(monkeypatch):
|
||
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", " https://agentlab.example.com/ ")
|
||
assert Settings().public_url == "https://agentlab.example.com"
|
||
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", "")
|
||
assert Settings().public_url == ""
|
||
# 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现
|
||
for broken in ("agentlab.example.com", "https://agentlab.example.com/?from=env"):
|
||
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", broken)
|
||
with pytest.raises(ValueError, match="AGENTLAB_PUBLIC_URL"):
|
||
Settings()
|
||
|
||
|
||
async def test_refresh_reads_the_stored_setting():
|
||
async with SessionLocal() as session:
|
||
session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL + "/"}))
|
||
await session.commit()
|
||
assert public_url.public_base_url() is None, "缓存还没对齐"
|
||
assert await public_url.refresh() == SET_URL
|
||
assert public_url.public_base_url() == SET_URL
|
||
|
||
|
||
# ---------------------------------------------------------------- 设置接口
|
||
|
||
|
||
async def test_saving_the_setting_refreshes_the_cache_without_restart():
|
||
async with client() as c:
|
||
assert (await put_address(c, SET_URL + "/")).status_code == 200
|
||
assert public_url.public_base_url() == SET_URL
|
||
body = (await c.get("/api/settings")).json()
|
||
assert body["public_url"] == {"value": SET_URL, "env_locked": False}
|
||
assert (await put_address(c, None)).status_code == 200
|
||
assert public_url.public_base_url() is None
|
||
|
||
|
||
async def test_env_locks_the_setting(monkeypatch):
|
||
monkeypatch.setattr(settings, "public_url", ENV_URL)
|
||
async with client() as c:
|
||
body = (await c.get("/api/settings")).json()
|
||
assert body["public_url"] == {"value": ENV_URL, "env_locked": True}
|
||
assert body[NOTIFY_GROUP]["public_base_url"] is None, "notify 一组照旧是库里存的那一份"
|
||
r = await put_address(c, SET_URL)
|
||
assert r.status_code == 409, r.text
|
||
assert r.json()["code"] == "public_url_env_locked" and r.json()["field"] == "notify.public_base_url"
|
||
assert "AGENTLAB_PUBLIC_URL" in r.json()["detail"]
|
||
# 没改这一项(整组原样写回)不算改:不挡同一请求里别的设置
|
||
assert (await put_address(c, None)).status_code == 200
|
||
# 同一组里别的项(待办汇总的时间)照常能改:环境变量只锁对外访问地址这一项
|
||
r = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "08:15"}}})
|
||
assert r.status_code == 200 and r.json()[NOTIFY_GROUP]["digest_at"] == "08:15"
|
||
# 只读的 public_url 一组写不进库
|
||
r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}})
|
||
assert r.status_code == 200
|
||
assert (await c.get("/api/settings")).json()["public_url"] == {"value": ENV_URL, "env_locked": True}
|
||
async with SessionLocal() as session:
|
||
assert await session.get(Setting, "public_url") is None
|
||
|
||
|
||
# ---------------------------------------------------------------- 用到它的地方
|
||
|
||
|
||
async def test_notifications_link_with_the_env_address_alone(monkeypatch):
|
||
"""只配了环境变量的部署,通知里也带链接(以前只认设置里的那一项)。"""
|
||
from app.notify import service
|
||
|
||
monkeypatch.setattr(settings, "public_url", ENV_URL)
|
||
async with SessionLocal() as session:
|
||
assert await service._base_url(session) == ENV_URL
|
||
monkeypatch.setattr(settings, "public_url", "")
|
||
async with SessionLocal() as session:
|
||
session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL}))
|
||
await session.commit()
|
||
async with SessionLocal() as session:
|
||
assert await service._base_url(session) == SET_URL
|
||
|
||
|
||
async def test_oidc_callback_uses_the_one_address(monkeypatch):
|
||
from starlette.requests import Request
|
||
|
||
from app.api.auth import _redirect_uri
|
||
|
||
request = Request({"type": "http", "method": "GET", "path": "/api/auth/oidc/start", "headers": [],
|
||
"scheme": "http", "server": ("10.0.0.5", 8000), "query_string": b""})
|
||
assert _redirect_uri(request) == "http://10.0.0.5:8000/api/auth/oidc/callback", "都没有:按请求的地址拼"
|
||
public_url.remember(SET_URL)
|
||
assert _redirect_uri(request) == f"{SET_URL}/api/auth/oidc/callback", "只在设置里填了的也按它拼"
|
||
monkeypatch.setattr(settings, "public_url", ENV_URL)
|
||
assert _redirect_uri(request) == f"{ENV_URL}/api/auth/oidc/callback"
|
||
|
||
|
||
async def test_origin_check_accepts_the_one_address():
|
||
await auth_env.reset()
|
||
try:
|
||
c = await auth_env.turn_on_local()
|
||
r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"})
|
||
assert r.status_code == 403 and r.json()["code"] == "csrf_origin"
|
||
public_url.remember(SET_URL)
|
||
r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"})
|
||
assert r.status_code == 201, r.text
|
||
finally:
|
||
await auth_env.reset()
|