From 9b97eafa1c5f349ff9b4aa1eca3cd9a328262bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 19 Oct 2026 17:27:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(middleware):=20/t//=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=89=8D=E7=BC=80=E8=A7=A3=E6=9E=90=EF=BC=88=E4=B8=8E?= =?UTF-8?q?=20Host=20=E6=A8=A1=E5=BC=8F=E5=85=B1=E5=AD=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TenantHostMiddleware 增路径前缀分支:raw_path 直判 slug 段(未编码 [a-z0-9-],%61uto/大写/点号不匹配落 Host 解析);仅带尾斜杠形态剥离; path 定长切割(保留前缀尾斜杠为剩余路径领头斜杠),raw_path 按解码 path 重编码重算(非长度截断),orig_path 留痕;未知 404/ws 1008、 停用 503/ws 1013 与 Host 模式同语义 - _PREFIX_RE 字符类取自 registry.TENANT_SLUG_RE(掐 ^$ 锚)单一事实源; tenant_routes 的 SLUG_RE 换源同常量别名 import(删本地定义与 import re) - 评审折入:registry 空 sites 分支注释纠正为生产可达(DB 源全站点停用 合法状态),test_registry 用例 docstring 同步;conftest ADMIN_PASSWORD 注释补硬编码登录用例警示 - 新增 TestPathPrefixRouting 13 用例(全链/拒绝族/停用/site 覆盖/豁免 优先/direct-ASGI 改写钉位/WS 双例/路径优先) Co-Authored-By: Claude Code --- app/platform/host_middleware.py | 66 ++++++++++- app/platform/registry.py | 4 +- app/platform/routes/tenant_routes.py | 8 +- tests/conftest.py | 3 +- tests/test_host_routing.py | 170 +++++++++++++++++++++++++++ tests/test_registry.py | 7 +- 6 files changed, 248 insertions(+), 10 deletions(-) diff --git a/app/platform/host_middleware.py b/app/platform/host_middleware.py index 9968e8e..9e9d172 100644 --- a/app/platform/host_middleware.py +++ b/app/platform/host_middleware.py @@ -16,6 +16,15 @@ scope type,两条路都先过租户解析。 admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免); - Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008); 租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013); +- 路径前缀模式(spec 2026-10-19 §3): ``/t//...`` 前缀优先于 Host + 解析(优先级:平台豁免 → 路径前缀 → Host)。slug 段在 raw_path 上 + ASCII 直判(未编码 ``^[a-z0-9-]+$``——``%61uto``/大写/点号一律不匹配, + 落 Host 解析);仅带尾斜杠形态剥离(``/t/x`` 无尾斜杠不剥);剥离后 + path 去前缀、raw_path 按解码 path 重编码重算(非长度截断),原始路径 + 留痕 ``state["orig_path"]``;未知 slug → 404/ws 1008,停用 → 503/ws + 1013(与 Host 模式同语义);站点默认取租户首站点(``?site=`` 覆盖链路 + 既有);``/t/x/api/platform/...`` 剥离后命中平台路由属 by design + (认证守护,spec §3.5); - 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state 与 scope["state"] 共享同一 dict(setdefault 保证),routes 的 get_runtime 即读到本中间件写入的 runtime。 @@ -24,12 +33,17 @@ Registry 延迟取: ``add_middleware`` 实例化时 lifespan 还没跑、 ``app.state.registry`` 未挂,构造期拿不到——从 ``scope["app"].state`` 逐请求取(Starlette 在中间件栈外层保证 scope 带 app)。 """ +import re +import urllib.parse + from starlette.responses import HTMLResponse, PlainTextResponse from starlette.types import ASGIApp, Receive, Scope, Send # 归一复用 registry 的私有实现——同一包内单一事实源,中间件与域名索引 -# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害) +# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害)。 +# TENANT_SLUG_RE 同理:slug 形态与创建校验(tenant_routes)单一事实源。 from app.platform.registry import ( + TENANT_SLUG_RE, TenantDisabledError, UnknownTenantError, _normalize_host, @@ -71,6 +85,17 @@ async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> Non class TenantHostMiddleware: """纯 ASGI 租户路由中间件(设计动机见模块 docstring)。""" + # 路径前缀形态(spec 2026-10-19 §3):raw_path 直判——slug 段必须 + # 未编码(%61uto 解码后即 auto,解码形态判不出编码;raw bytes 上 + # [a-z0-9-] 天然排除 % 与大写)。仅带尾斜杠形态剥离(/t/x 不剥)。 + # slug 字符类取自 registry 的 TENANT_SLUG_RE(掐掉 ^$ 锚)——与创建 + # 校验单一事实源,永不漂移。 + # 安全注记:slug 只作 registry 的 dict lookup key,绝不插入任何 + # 输出(404/503 均静态响应)——无注入面。 + _PREFIX_RE = re.compile( + rb"^/t/(" + TENANT_SLUG_RE.pattern.strip("^$").encode("ascii") + + rb")(/.*)?$", re.ASCII) + def __init__(self, app: ASGIApp) -> None: self.app = app @@ -116,6 +141,45 @@ class TenantHostMiddleware: "app.state.registry 未挂载——TenantHostMiddleware 需要 " "lifespan(或测试注入缝)先加载 TenantRegistry") + # 路径前缀模式(spec 2026-10-19 §3):/t// 前缀优先于 Host + # 解析。不匹配(编码/大写/点号/无尾斜杠)一律不剥离,落 Host 解析 + # (共享域名下即 404)——group(2) None = /t/x 无尾斜杠 → 不剥离。 + raw = scope.get("raw_path") or b"" + m = self._PREFIX_RE.match(raw) + if m and m.group(2) is not None: + slug = m.group(1).decode("ascii") + try: + runtime, site = await registry.resolve_by_slug(slug) + except UnknownTenantError: + if scope["type"] == "websocket": + await _close_ws(receive, send, 1008, "unknown tenant") + else: + await PlainTextResponse("Not Found", status_code=404)( + scope, receive, send) + return + except TenantDisabledError: + if scope["type"] == "websocket": + await _close_ws(receive, send, 1013, "service paused") + else: + await HTMLResponse(PAUSE_PAGE, status_code=503)( + scope, receive, send) + return + state = scope.setdefault("state", {}) + state["orig_path"] = scope.get("path") or "" # 排障留痕(§3.3) + # 剥离量按解码 path 对应段计:slug 段在 raw 上未编码 → 解码/ + # 原始同长,定长切割只切 "/t/"+slug 本身(len 3+slug),前缀的 + # 尾斜杠保留为剩余路径的领头斜杠(/t/jiancai/ → 恰剩 "/")。 + # 其后段的编码差异不影响这段定长切割 + scope["path"] = scope["path"][len("/t/") + len(slug):] or "/" + # raw_path 不按长度截断,按剥离后的解码 path 重编码重算 + # (spec §3.2——编码形态下解码/原始长度不一致,%2F 等会错位) + scope["raw_path"] = urllib.parse.quote( + scope["path"], safe="/:@!$&'()*+,;=-._~").encode("ascii") + state["tenant"] = runtime + state["site"] = site + await self.app(scope, receive, send) + return + try: runtime, site = await registry.resolve_site(_host_from_scope(scope)) except UnknownTenantError: diff --git a/app/platform/registry.py b/app/platform/registry.py index 837cc68..46667ad 100644 --- a/app/platform/registry.py +++ b/app/platform/registry.py @@ -242,7 +242,9 @@ class TenantRegistry: raise TenantDisabledError(f"租户已停用: {slug!r}") rt = await self._ensure_ready(rt) if not rt.config.sites: - # 创建向导恒建 main 站点,此分支为防御(spec §5) + # 全站点停用的租户(DB 源合法状态:tenant_source 过滤 active + # 站点后可发出 sites=[],见其 226-235 行注记)——路径模式无从 + # 选站点 → 按未知处理(404)。非防御分支,生产可达 raise UnknownTenantError( f"租户 {slug!r} 无站点——路径模式无从选站点") return rt, rt.config.sites[0] diff --git a/app/platform/routes/tenant_routes.py b/app/platform/routes/tenant_routes.py index d6e0e07..17c697f 100644 --- a/app/platform/routes/tenant_routes.py +++ b/app/platform/routes/tenant_routes.py @@ -31,8 +31,6 @@ tenant_source.py(配置 API 的重置出厂同样消费);版本 1 写入改走 db.write_config_version(与配置 API 的 PUT/rollback/reset 同一版本写入 路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。 """ -import re - from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy import func, select @@ -55,14 +53,16 @@ from app.platform.db import ( other_push_prefixes, write_config_version, ) -from app.platform.registry import _normalize_host +# slug 形态校验的单一事实源在 registry(TENANT_SLUG_RE——创建校验与 +# 路径前缀解析 /t// 共用,永不漂移,spec 2026-10-19 §3.1);别名 +# import 让本文件既有 SLUG_RE 引用零改动 +from app.platform.registry import TENANT_SLUG_RE as SLUG_RE, _normalize_host from app.platform.tenant_source import _profile_snapshot router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"]) PROFILES = ("auto", "jiancai", "blank") STATUSES = ("active", "disabled") -SLUG_RE = re.compile(r"^[a-z0-9-]+$") # ---- 校验助手 ------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index bd521fd..7c96ef9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,8 @@ from app.engine.models import Base # 测试环境封闭(2026-10-19 修复):Settings(BaseSettings) 会读仓库 .env—— # 开发机 .env 的 ADMIN_PASSWORD(如 Docker 测试环境的强密码)会盖过 lifespan # 引导的出厂值 admin123,测试登录全员 401。真实环境变量优先级本就高于 -# .env,这里把测试期望值钉进 os.environ(已显式设置则不覆盖,CI 可另行注入)。 +# .env,这里把测试期望值钉进 os.environ(已显式设置则不覆盖,CI 可另行 +# 注入;注入非 admin123 值会破坏 ~20 个硬编码登录用例)。 os.environ.setdefault("ADMIN_PASSWORD", "admin123") # Task 5b 装配切换: app 不再有全局 engine/async_session——旧 conftest 里 diff --git a/tests/test_host_routing.py b/tests/test_host_routing.py index 7119672..97136a5 100644 --- a/tests/test_host_routing.py +++ b/tests/test_host_routing.py @@ -7,12 +7,14 @@ import json import shutil +from types import SimpleNamespace import pytest from starlette.websockets import WebSocketDisconnect from app.main import app from app.platform.host_middleware import TenantHostMiddleware +from app.platform.registry import TenantRegistry from tests.conftest import ( AUTO_HOST, JIANCAI_HOST, @@ -150,3 +152,171 @@ def test_ws_routes_to_correct_tenant_db(app_client, tmp_path): assert r_jc.status_code == 200 assert r_jc.json()["is_new"] is True assert r_jc.json()["session_id"] != sid + + +# ---- 路径前缀模式(/t//,spec 2026-10-19 §3) ---------------------------- + + +class TestPathPrefixRouting: + """路径前缀与 Host 模式共存:优先级 平台豁免 → 路径前缀 → Host 解析。 + + Host 用未知值(NOPE_HOST)——路径命中即证明解析先于 Host;反向用已知 + AUTO_HOST(Host 说是 auto 租户)+ /t/jiancai/ 断言路径优先(spec §2)。""" + + def test_聊天页_t_slug_根路径(self, app_client): + r = app_client.get("/t/jiancai/", headers={"host": NOPE_HOST}) + assert r.status_code == 200 + assert "chat" in r.text.lower() + + def test_静态与公开API全链(self, app_client): + r = app_client.get("/t/jiancai/static/widget.js", + headers={"host": NOPE_HOST}) + assert r.status_code == 200 + r = app_client.get("/t/jiancai/api/public/brand", + headers={"host": NOPE_HOST}) + assert r.status_code == 200 + assert r.json()["slug"] == "jiancai" + + def test_无尾斜杠不剥离_走Host解析404(self, app_client): + # 只断言未知 Host 形态:/t/x 在 Host 模式下本就无路由(/ 才是聊天页), + # 若带已知 Host 断言 200 会诱导实现去剥离无斜杠形态——违反 spec §3.2 + r = app_client.get("/t/jiancai", headers={"host": NOPE_HOST}) + assert r.status_code == 404 + # 已知 Host 的正常根路径(Host 模式对照,不受路径模式影响) + assert app_client.get("/", headers={"host": JIANCAI_HOST}).status_code == 200 + + def test_编码slug拒绝(self, app_client): + # %61uto 解码后即合法 slug auto——解码形态判不出编码,raw_path 直判 + # 为准(spec §3.1);不匹配 → 落 Host 解析(未知 Host)→ 404 + r = app_client.get("/t/%61uto/", headers={"host": NOPE_HOST}) + assert r.status_code == 404 + + def test_大写与点号拒绝(self, app_client): + # 不能用 /t/../:httpx 客户端侧归一点段到不了中间件——用字面点号 + assert app_client.get( + "/t/Jiancai/", headers={"host": NOPE_HOST}).status_code == 404 + assert app_client.get( + "/t/jian.cai/", headers={"host": NOPE_HOST}).status_code == 404 + + def test_未知slug_404(self, app_client): + r = app_client.get("/t/no-such/x", headers={"host": NOPE_HOST}) + assert r.status_code == 404 + + def test_停用租户_503(self, disabled_client): + # 路径模式同样吃 TenantDisabledError → 503 中性暂停页(与 Host 模式 + # 同语义;此处 Host 头无关紧要,解析根本不走到 Host) + r = disabled_client.get("/t/jiancai/api/chat/session", + headers={"host": NOPE_HOST}) + assert r.status_code == 503 + assert "暂停" in r.text + + def test_site参数覆盖在路径模式生效(self, app_client): + # jiancai 的 es 站点(空域名站点):默认取首站点 main,?site= 覆盖 + # 链路既有(spec §3.4)——brand title 取站点覆盖值即证覆盖真生效 + r = app_client.get("/t/jiancai/api/public/brand?site=es", + headers={"host": NOPE_HOST}) + assert r.status_code == 200 + assert r.json()["title"] == "HESUPIPE Soporte" + + def test_平台豁免优先级不受影响(self, app_client): + # /health 豁免不受路径模式影响;/t/x/api/platform/... 剥离后命中 + # 平台路由属 by design(spec §3.5)——认证守护兜底,未带凭据 + # 401/422/429(POST 空 body → 422) + assert app_client.get("/health", headers={"host": NOPE_HOST}).status_code == 200 + r = app_client.post("/t/jiancai/api/platform/auth/login", + headers={"host": NOPE_HOST}) + assert r.status_code in (401, 422, 429) + + async def test_orig_path与路径改写直接钉位(self, tmp_path): + """直接构造 ASGI scope 断言改写(不经 httpx 归一化):capture app + 记录中间件改写后的 path/raw_path/state["orig_path"]——spec §3.2/ + §3.3 的直证。registry 手工装配,用完 dispose_all(Windows 文件锁)。""" + reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path) + captured = {} + + async def capture_app(scope, receive, send): + state = scope.get("state") or {} + captured.update( + path=scope.get("path"), + raw_path=scope.get("raw_path"), + orig_path=state.get("orig_path"), + tenant=(state.get("tenant").config.slug + if state.get("tenant") else None), + site=state.get("site").slug if state.get("site") else None, + ) + + async def noop_receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def noop_send(message): + pass + + def make_scope(rtype, path, raw_path): + return { + "type": rtype, "asgi": {"version": "3.0"}, + "http_version": "1.1", "method": "GET", "scheme": "http", + "path": path, "raw_path": raw_path, "query_string": b"", + "headers": [(b"host", b"unknown.example")], + "client": ("127.0.0.1", 12345), + "app": SimpleNamespace(state=SimpleNamespace(registry=reg)), + } + + mw = TenantHostMiddleware(capture_app) + try: + # http:基本剥离 + orig_path 留痕 + state 写入 + await mw(make_scope("http", "/t/jiancai/static/widget.js", + b"/t/jiancai/static/widget.js"), + noop_receive, noop_send) + assert captured["path"] == "/static/widget.js" + assert captured["raw_path"] == b"/static/widget.js" + assert captured["orig_path"] == "/t/jiancai/static/widget.js" + assert captured["tenant"] == "jiancai" + assert captured["site"] == "main" + + # raw_path 重编码而非长度截断(spec §3.2):%2F 解码成 "/" 后, + # 截断会得 b"/a%2Fb",重算得 b"/a/b" + await mw(make_scope("http", "/t/jiancai/a/b", + b"/t/jiancai/a%2Fb"), + noop_receive, noop_send) + assert captured["path"] == "/a/b" + assert captured["raw_path"] == b"/a/b" + + # 根路径形态:/t/jiancai/ → "/"(剥空兜底) + await mw(make_scope("http", "/t/jiancai/", b"/t/jiancai/"), + noop_receive, noop_send) + assert captured["path"] == "/" + assert captured["raw_path"] == b"/" + + # websocket scope 同构改写(不必真握手,capture 收到即证) + await mw(make_scope("websocket", "/t/jiancai/ws/chat", + b"/t/jiancai/ws/chat"), + noop_receive, noop_send) + assert captured["path"] == "/ws/chat" + assert captured["orig_path"] == "/t/jiancai/ws/chat" + finally: + await reg.dispose_all() + + def test_WS路径模式握手(self, app_client): + # WS 握手后服务端先等客户端 init 消息(chat.py:48)——先发 + # standalone 再收 init;session_id 即证路径模式 WS 全链打通 + with app_client.websocket_connect( + "/t/jiancai/ws/chat", headers={"host": NOPE_HOST}) as ws: + ws.send_text(json.dumps({"source": "standalone"})) + data = ws.receive_json() + assert data["type"] == "init" + assert "session_id" in data + + def test_WS未知slug拒绝(self, app_client): + with pytest.raises(WebSocketDisconnect) as ei: + with app_client.websocket_connect( + "/t/no-such/ws/chat", headers={"host": NOPE_HOST}): + pass # pragma: no cover — 未 accept 即 close,进不来 + assert ei.value.code == 1008 + + def test_Host模式回归_路径优先(self, app_client): + # 已知 Host(AUTO_HOST 属 auto 租户)+ /t/jiancai/ → 出 jiancai + # 而非 auto,证明路径前缀解析赢过 Host 解析(spec §2 优先级) + r = app_client.get("/t/jiancai/api/public/brand", + headers={"host": AUTO_HOST}) + assert r.status_code == 200 + assert r.json()["slug"] == "jiancai" diff --git a/tests/test_registry.py b/tests/test_registry.py index e68b1be..a2b81a3 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -329,9 +329,10 @@ class TestResolveBySlug: await reg.dispose_all() async def test_空站点表按未知处理(self, tmp_path): - """防御分支(spec §5):两源加载期均拒零站点,直装 sites=[] 的 - LoadedTenant 绕过源校验覆盖该分支——正常运行不可达,防将来源 - 语义漂移。""" + """空站点表 → 按未知处理(404)。该分支生产可达而非防御:DB 源 + 全站点停用是合法运营状态(tenant_source 过滤 active 站点后可发出 + sites=[])。文件源加载期拒零站点——直装 sites=[] 的 LoadedTenant + 绕过源校验直接覆盖此分支。""" seed = _write_seed(tmp_path, {"jiancai": _tenant("jiancai")}) source = FileTenantSource(seed) lt = dataclasses.replace(source.read_tenant("jiancai"), sites=[])