Files
AgentLab/backend/tests/test_public_url.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

173 lines
8.2 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.
"""对外访问地址只有一个说法(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()