From a43c654337f420d02095ce57a6cd00c08b6c3607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 10:35:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=EF=BC=88uuid=E9=87=8D=E5=91=BD=E5=90=8D/=E9=AD=94=E6=95=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C/2MB/=E9=85=8D=E7=BD=AE=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=EF=BC=89=E4=B8=8E=E4=B8=89=E7=BA=A7=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/platform/tenants/{slug}/brand/avatar(require_tenant): multipart 收文件,魔数判定类型(\x89PNG/\xff\xd8\xff/RIFF....WEBP, 优先于扩展名——改后缀骗不过,存盘扩展名取自内容)、≤2MB(413, upload.size 预检免拷贝)、服务端 uuid4 重命名(原始文件名不参与 路径),落 data/uploads//(目录懒建,gitignore 已忽略) - 走 Task 6 配置版本管线(复用 config_routes 内部 helper):读当前 快照 → brand 组只替换 avatar 字段 → validate_group → write_config_version(comment=头像上传) → registry_reload 热生效 - /api/public/avatar 三级解析链:uploads → seed 资产 → 静态兜底; Phase 1 文件名穿越防线(拒 / \ . ..)扩到 uploads 段 - registry.uploads_root:自 data_dir.parent 派生(生产=/data/ uploads;测试注入 tmp data_dir 时自动落 tmp 内,隔离免污染) - 新依赖 python-multipart(multipart 表单解析) Co-Authored-By: Claude Code --- app/engine/routes/public.py | 40 +++-- app/main.py | 4 + app/platform/registry.py | 10 +- app/platform/routes/upload_routes.py | 109 +++++++++++++ requirements.txt | 1 + tests/test_avatar_upload.py | 235 +++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 app/platform/routes/upload_routes.py create mode 100644 tests/test_avatar_upload.py diff --git a/app/engine/routes/public.py b/app/engine/routes/public.py index 8d316de..b4d0d11 100644 --- a/app/engine/routes/public.py +++ b/app/engine/routes/public.py @@ -9,8 +9,10 @@ - ``?site=`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码 ``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site (即租户默认门面); -- avatar 按当前租户解析 ``seed_profiles//assets/``, - 文件不存在回退 ``static/kefu-small.png``(两租户头像各归各,字节级不同)。 +- avatar 按当前租户走**三级解析链**(Task 7):``data/uploads// + ``(后台上传的头像,优先)→ ``seed_profiles// + assets/<同名>``(出厂档案资产)→ ``static/kefu-small.png`` 兜底。文件名 + 的穿越防线(拒 ``/`` ``\\`` ``.`` ``..``,Phase 1)同样覆盖 uploads 段。 中间件已保证到达路由的请求必有 state.tenant/state.site;本路由照常经 中间件(租户域名下访问),经 get_runtime 取运行时。 @@ -73,20 +75,28 @@ async def avatar(request: Request): runtime = get_runtime(request) site, _ = _effective_site(request, runtime) # 只取文件名(PurePosixPath.name 剥掉一切路径成分)——brand.avatar 是 - # "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径 - # 逃逸面(单成分文件名不可能越出 assets/) + # "/static/kefu-small.png" 或上传后的 ".png" 这类值,取基名后拼 + # 租户目录,无路径逃逸面(单成分文件名不可能越出目录) name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name # 复审加固: PurePosixPath 不认反斜杠(整串留在 .name 里),Windows 文件 - # 系统却把反斜杠当分隔符——seed_dir/assets/.. 会真穿出资产目录 serve - # 任意文件。显式拒绝分隔符与目录点,命中即回退静态兜底。 + # 系统却把反斜杠当分隔符——assets/.. 会真穿出资产目录 serve 任意文件。 + # 显式拒绝分隔符与目录点,命中即回退静态兜底。防线覆盖整条解析链 + # (uploads 段与 seed 段消费同一个守卫后的 name)。 if name in {".", ".."} or "/" in name or "\\" in name: name = "" - # seed_dir=None = DB 源租户(Task 4;资产目录尚无 DB 侧存储)——直接 - # 落静态兜底,不让 None 进路径拼接炸 500 - candidate = (runtime.seed_dir / "assets" / name - if name and runtime.seed_dir is not None else None) - if candidate is not None and candidate.is_file(): - return FileResponse( - candidate, headers={"Access-Control-Allow-Origin": "*"}) - return FileResponse( - _AVATAR_FALLBACK, headers={"Access-Control-Allow-Origin": "*"}) + headers = {"Access-Control-Allow-Origin": "*"} + if name: + # 三级解析链(Task 7):uploads(后台上传,优先)→ seed 资产 → 静态 + # 兜底。uploads_root 生产行为见 registry(仓库根 data/,gitignore); + # registry 经中间件保证存在(本请求已过 Host 解析)。 + uploaded = (request.app.state.registry.uploads_root + / runtime.config.slug / name) + if uploaded.is_file(): + return FileResponse(uploaded, headers=headers) + # seed_dir=None = DB 源租户(Task 4;出厂资产无 DB 侧存储)—— + # 跳过 seed 段落静态兜底,不让 None 进路径拼接炸 500 + if runtime.seed_dir is not None: + seeded = runtime.seed_dir / "assets" / name + if seeded.is_file(): + return FileResponse(seeded, headers=headers) + return FileResponse(_AVATAR_FALLBACK, headers=headers) diff --git a/app/main.py b/app/main.py index 5aedb98..4e2b4ae 100644 --- a/app/main.py +++ b/app/main.py @@ -86,6 +86,7 @@ from app.platform.routes.config_routes import ( settings_router as platform_settings_router, ) from app.platform.routes.tenant_routes import router as platform_tenant_router +from app.platform.routes.upload_routes import router as platform_upload_router app.include_router(chat_router) app.include_router(chat_rest_router) app.include_router(public_router) @@ -97,6 +98,9 @@ app.include_router(platform_tenant_router) # 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。 app.include_router(platform_config_router) app.include_router(platform_settings_router) +# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线 +# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。 +app.include_router(platform_upload_router) # 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下 # /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为 # /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析); diff --git a/app/platform/registry.py b/app/platform/registry.py index 503d38c..7d6663d 100644 --- a/app/platform/registry.py +++ b/app/platform/registry.py @@ -90,8 +90,8 @@ class TenantRuntime: 加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/ db_path(该租户 SQLite 文件路径)/seed_dir(文件源=租户目录;DB 源 - None——avatar 解析链对其回退静态兜底)/compiled_wall(墙编译产物)/ - prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一 + None——avatar 解析链的 seed 资产段对其跳过)/compiled_wall(墙编译 + 产物)/prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一 入口——$DEFLECTION_POOL 话术池注入的单一消费口)。 懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。 manager(Task 5b)由装配线另行构造注入,不在本类持有。 @@ -123,6 +123,12 @@ class TenantRegistry: self._by_slug: dict[str, TenantRuntime] = {} self._domain_index: dict[str, tuple[str, str]] = {} self._locks: dict[str, asyncio.Lock] = {} + # 头像上传根目录(Task 7):生产 data_dir = /tenants,与 + # /data/ 是仓库根兄弟目录(spec 布局,.gitignore 已忽略 data/) + # → 取 data_dir.parent 派生;测试注入 tmp data_dir 时上传自动落 + # tmp 内(DB 源夹具 data_dir=tmp/tenants → tmp/data/uploads), + # 隔离免污染仓库目录。布局若变,只改这一处。 + self.uploads_root = self._data_dir.parent / "data" / "uploads" # ---- 加载 ----------------------------------------------------------- diff --git a/app/platform/routes/upload_routes.py b/app/platform/routes/upload_routes.py new file mode 100644 index 0000000..0bf502c --- /dev/null +++ b/app/platform/routes/upload_routes.py @@ -0,0 +1,109 @@ +"""头像上传(Phase 2 Task 7):POST /api/platform/tenants/{slug}/brand/avatar。 + +安全纪律(三防): +- **魔数校验优先于扩展名**:类型只认文件内容(\x89PNG / \xff\xd8\xff / + RIFF....WEBP),改后缀骗不过——.png 名的 JPEG 按内容存 .jpg,纯文本/ + RIFF 非 WEBP 一律 400。存盘扩展名取自魔数判定的规范名,自洽无伪装面; +- **服务端 uuid 重命名**:uuid4().hex + "." + 扩展,原始文件名不参与 + 路径(带 ../ 的文件名无从生效);目录段 slug 取自 DB 行(tenant.slug), + 路径参数先过 _get_tenant_or_404 才拼盘——未知/伪造 slug 到不了文件系统; +- **大小 ≤2MB**(413);缺 file 字段 400(不留给 FastAPI 422 抢答,与 + 配置 API 的 400 契约一致)。 + +配置版本管线(与 Task 6 的 PUT 同款模式,复用 config_routes 内部 helper): +读当前快照 → brand 组**只替换 avatar 字段**(其余字段原样保留——与 PUT +的整组替换不同,上传是单字段编辑)→ validate_group(brand) → +write_config_version(comment="头像上传") → registry_reload 热生效。 + +落盘顺序:校验全过 → 写文件(uploads_root//,目录懒建)→ 写版本 +→ commit → reload。文件先落盘:commit 失败只留无害孤儿文件;反过来 +(先版本后文件)失败会留下指向不存在文件的 avatar。 +""" +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +# request.form() 产出 starlette 的 UploadFile;fastapi.UploadFile 是其子类 +# (File() 参数专用),isinstance 反向判 False——必须对基类判 +from starlette.datastructures import UploadFile + +from app.platform.config_validation import ValidationContext, validate_group +from app.platform.db import write_config_version +from app.platform.routes.config_routes import _load_current, _updated_by +from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard + +router = APIRouter(prefix="/api/platform/tenants", tags=["platform-upload"]) + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2MB(spec 头像上限) + +# 魔数 → 规范扩展名(检测只认内容;存盘名与之自洽) +_MAGIC_SIGNATURES = ( + (b"\x89PNG\r\n\x1a\n", "png"), + (b"\xff\xd8\xff", "jpg"), +) + + +def _sniff_image(data: bytes) -> str | None: + """按魔数嗅探图片类型,返回规范扩展名;非 png/jpg/webp → None。""" + for signature, ext in _MAGIC_SIGNATURES: + if data.startswith(signature): + return ext + # WEBP 容器: "RIFF" + 4 字节长度 + "WEBP"(头尾两段,无法单串 startswith) + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + return None + + +@router.post("/{slug}/brand/avatar") +async def upload_avatar(slug: str, request: Request, + user=Depends(_tenant_guard)): + """multipart 头像上传:魔数判定类型 → uuid 重命名落盘 → 配置版本管线 + 写 brand.avatar → reload 热生效。返回 {slug, avatar, version}。""" + form = await request.form() + upload = form.get("file") + if upload is None or not isinstance(upload, UploadFile): + raise HTTPException( + status_code=400, detail="缺少 multipart 文件字段 file") + # 预检 upload.size(starlette 解析期已跟踪的实际字节数):超标直接 413, + # 不把超大 body 再 read() 进内存(spool 盘上的部分不复制) + if upload.size is not None and upload.size > MAX_AVATAR_BYTES: + raise HTTPException( + status_code=413, + detail=f"头像超过 2MB 上限(实际 {upload.size} 字节)") + data = await upload.read() + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException( + status_code=413, + detail=f"头像超过 2MB 上限(实际 {len(data)} 字节)") + ext = _sniff_image(data) + if ext is None: + raise HTTPException( + status_code=400, + detail="不支持的图片类型: 只允许 png/jpg/jpeg/webp" + "(按文件内容判定,改后缀无效)") + filename = f"{uuid.uuid4().hex}.{ext}" + + engine = request.app.state.platform_engine + async with AsyncSession(engine, expire_on_commit=False) as session: + tenant = await _get_tenant_or_404(session, slug) + _, current = await _load_current(session, tenant.id, slug) + brand = dict(current.get("brand") or {}) + brand["avatar"] = filename + errors = validate_group("brand", brand, ValidationContext()) + if errors: # 管线纪律:落库前过校验(理论不触发) + raise HTTPException(status_code=400, detail="; ".join(errors)) + new_snapshot = dict(current) + new_snapshot["brand"] = brand + version = await write_config_version( + session, tenant.id, new_snapshot, + updated_by=_updated_by(user), comment="头像上传") + + # 目录段用 DB 行的 slug(路径 slug 已过 404 守卫,二者必一致—— + # 但以库为准拼路径,杜绝任何路径参数形态的逃逸面) + dest_dir = request.app.state.registry.uploads_root / tenant.slug + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / filename).write_bytes(data) + await session.commit() + + await request.app.state.registry.registry_reload(slug) + return {"slug": tenant.slug, "avatar": filename, "version": version} diff --git a/requirements.txt b/requirements.txt index bb1b53f..d2bac47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ httpx>=0.27.0 pydantic>=2.0.0 pydantic-settings>=2.0.0 python-dotenv>=1.0.0 +python-multipart>=0.0.20 sniffio>=1.3.0 iptocc>=0.1.0 phonenumberslite>=9.0 diff --git a/tests/test_avatar_upload.py b/tests/test_avatar_upload.py new file mode 100644 index 0000000..0550462 --- /dev/null +++ b/tests/test_avatar_upload.py @@ -0,0 +1,235 @@ +"""头像上传与解析链(Phase 2 Task 7)。 + +POST /api/platform/tenants/{slug}/brand/avatar(multipart,require_tenant): +- 魔数校验优先于扩展名——改后缀骗不过(.png 名的 JPEG 按内容存 .jpg; + RIFF 但非 WEBP 拒;纯文本拒); +- ≤2MB(超出 413);服务端 uuid 重命名,原始文件名不参与路径; +- 走配置版本管线(快照 brand 组改 avatar 字段 → validate_group → + write_config_version(comment=头像上传)→ registry_reload); +- /api/public/avatar 三级解析链: data/uploads// → + seed_profiles//assets/ → static/kefu-small.png;文件名的 + 穿越防线(PHase 1)扩到 uploads 段。 + +夹具 db_source_app/super_client(conftest);助手复用 test_tenant_admin_api。 +上传落盘位置 = registry.uploads_root(DB 源 tmp 布局下 = tmp_path/data/ +uploads,隔离免污染;生产行为见 registry.uploads_root 注释)。 +""" +import re +from pathlib import Path + +from tests.conftest import SEED_DIR +from tests.test_tenant_admin_api import ( + _auth, + _bearer, + _create, + _tenant_admin_token, +) + +REPO = Path(__file__).resolve().parents[1] +TENANTS = "/api/platform/tenants" +LOGIN = "/api/platform/auth/login" + +# 最小"合法头"测试字节——魔数校验只看头几个字节,后续填充唯一标记 +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"avatar-body-UNIQUE-42" +JPG_BYTES = b"\xff\xd8\xff" + b"jpeg-body" +WEBP_BYTES = b"RIFF" + b"\x24\x00\x00\x00" + b"WEBP" + b"VP8 webp-body" +UUID_EXT_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$") + + +def _upload(client, slug, name, data, content_type="image/octet-stream", + headers=None): + return client.post(f"{TENANTS}/{slug}/brand/avatar", + headers=headers or _auth(client), + files={"file": (name, data, content_type)}) + + +def _config(client, slug): + r = client.get(f"{TENANTS}/{slug}/config", headers=_auth(client)) + assert r.status_code == 200, r.text + return r.json() + + +def _avatar(client, host): + return client.get("/api/public/avatar", headers={"host": host}) + + +# ---- 上传 → 版本管线 → 热生效 ------------------------------------------------------ + + +def test_upload_and_serve(super_client, db_source_app, tmp_path): + _create(super_client, slug="demo", domains=["demo.example.com"]) + fallback = (REPO / "static" / "kefu-small.png").read_bytes() + + # 基线:DB 源租户(无 seed 资产目录)→ 静态兜底 + r = _avatar(super_client, "demo.example.com") + assert r.status_code == 200 + assert r.content == fallback + + r = _upload(super_client, "demo", "me.png", PNG_BYTES, "image/png") + assert r.status_code == 200, r.text + body = r.json() + # 服务端 uuid 重命名:uuid4 hex + 魔数判定的扩展名;原始文件名不出现 + assert UUID_EXT_RE.fullmatch(body["avatar"]), body["avatar"] + assert body["avatar"].endswith(".png") + assert "me.png" not in body["avatar"] + + # 落盘:data/uploads//.(目录懒建),字节原样 + stored = tmp_path / "data" / "uploads" / "demo" / body["avatar"] + assert stored.is_file() + assert stored.read_bytes() == PNG_BYTES + + # 配置版本管线:版本 +1,brand 组只改 avatar 字段(其余字段原样保留), + # 版本历史 comment 记"头像上传" + cfg = _config(super_client, "demo") + assert cfg["version"] == 2 + assert cfg["config"]["brand"]["avatar"] == body["avatar"] + assert cfg["config"]["brand"]["title"] == "HESUPIPE Support" # jiancai 档案值 + versions = super_client.get(f"{TENANTS}/demo/config/versions", + headers=_auth(super_client)).json()["items"] + assert versions[-1]["comment"] == "头像上传" + + # 热生效:registry_reload 后 /api/public/avatar(该租户 Host)返回新图字节 + r = _avatar(super_client, "demo.example.com") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("image/") + assert r.content == PNG_BYTES + assert r.content != fallback + + +def test_magic_beats_extension(super_client, tmp_path): + """魔数优先于扩展名:内容是 JPEG 的 .png 名文件按内容收为 .jpg; + webp 魔数(RIFF....WEBP)同理。""" + _create(super_client, slug="demo") + r = _upload(super_client, "demo", "sneaky.png", JPG_BYTES, "image/png") + assert r.status_code == 200, r.text + assert r.json()["avatar"].endswith(".jpg") + + r = _upload(super_client, "demo", "logo.webp", WEBP_BYTES, "image/webp") + assert r.status_code == 200, r.text + assert r.json()["avatar"].endswith(".webp") + + +# ---- 拒收面:类型 / 大小 ------------------------------------------------------------ + + +def test_reject_bad_type_and_oversize(super_client, tmp_path): + _create(super_client, slug="demo") + # .txt(内容也非图)→ 400 + r = _upload(super_client, "demo", "a.txt", b"hello plain text", "text/plain") + assert r.status_code == 400 + # .png 后缀但内容非 png(魔数不符)→ 400,改后缀骗不过 + r = _upload(super_client, "demo", "fake.png", b"definitely not an image", + "image/png") + assert r.status_code == 400 + # RIFF 头但非 WEBP(如 wav)→ 400 + r = _upload(super_client, "demo", "a.wav", + b"RIFF\x24\x00\x00\x00WAVEfmt ", "audio/wav") + assert r.status_code == 400 + # 3MB(合法 png 头)→ 413 + big = b"\x89PNG\r\n\x1a\n" + b"x" * (3 * 1024 * 1024) + r = _upload(super_client, "demo", "big.png", big, "image/png") + assert r.status_code == 413 + # 缺 file 字段 → 400(不是 422/500) + r = super_client.post(f"{TENANTS}/demo/brand/avatar", + headers=_auth(super_client), data={"x": "1"}) + assert r.status_code == 400 + # 全部拒收:版本不涨、盘上无文件 + assert _config(super_client, "demo")["version"] == 1 + uploads_dir = tmp_path / "data" / "uploads" / "demo" + assert not uploads_dir.exists() or not any(uploads_dir.iterdir()) + # 恰在上限内(2MB 整)→ 通过 + ok = b"\x89PNG\r\n\x1a\n" + b"y" * (2 * 1024 * 1024 - 8) + assert len(ok) == 2 * 1024 * 1024 + r = _upload(super_client, "demo", "edge.png", ok, "image/png") + assert r.status_code == 200, r.text + + +# ---- 穿越三防 --------------------------------------------------------------------- + + +def test_traversal_safe(super_client, db_source_app, tmp_path): + _create(super_client, slug="demo", domains=["demo.example.com"]) + # 防 1:上传文件名带 ../ 不生效——服务端 uuid 重命名,路径成分不落盘 + r = _upload(super_client, "demo", "../../evil.png", PNG_BYTES, "image/png") + assert r.status_code == 200, r.text + avatar = r.json()["avatar"] + assert UUID_EXT_RE.fullmatch(avatar), avatar + assert (tmp_path / "data" / "uploads" / "demo" / avatar).is_file() + assert not (tmp_path / "data" / "uploads" / "evil.png").exists() + assert not (tmp_path / "evil.png").exists() + assert not (tmp_path / "data" / "evil.png").exists() + + # 防 2:解析链 uploads 段拒绝路径字符——brand.avatar 指到 uploads 目录外 + # 的真实文件(Windows 反斜杠语义下 ..\secret.txt 会穿出 uploads/demo/), + # 命中防线即回退静态兜底,绝不 serve 穿越目标 + secret = tmp_path / "data" / "uploads" / "secret.txt" + secret.write_bytes(b"TOPSECRET-never-serve") + r = super_client.put(f"{TENANTS}/demo/config/brand", + headers=_auth(super_client), + json={"avatar": "..\\secret.txt"}) + assert r.status_code == 200, r.text + r = _avatar(super_client, "demo.example.com") + assert r.status_code == 200 + assert b"TOPSECRET" not in r.content + assert r.content == (REPO / "static" / "kefu-small.png").read_bytes() + + # 防 3:正斜杠形态(PurePosixPath 剥成基名,单成分不可能越出 uploads//) + r = super_client.put(f"{TENANTS}/demo/config/brand", + headers=_auth(super_client), + json={"avatar": "../../secret.txt"}) + assert r.status_code == 200, r.text + r = _avatar(super_client, "demo.example.com") + assert r.status_code == 200 + assert b"TOPSECRET" not in r.content + + +# ---- 鉴权边界(require_tenant:401/409/403,本租户管理员放行,未知租户 404) ---------- + + +def test_upload_auth_boundaries(super_client): + _create(super_client, slug="demo") + _create(super_client, slug="other") + url = f"{TENANTS}/demo/brand/avatar" + + # 未认证 → 401 + r = super_client.post(url, files={"file": ("a.png", PNG_BYTES, "image/png")}) + assert r.status_code == 401 + + # 引导密码未改(登录即持 must_change 标志)→ 409 + r = super_client.post(LOGIN, json={"username": "demo-admin", + "password": "demo-pass-1"}) + fresh_token = r.json()["token"] + r = super_client.post(url, headers=_bearer(fresh_token), + files={"file": ("a.png", PNG_BYTES, "image/png")}) + assert r.status_code == 409 + + # 其他租户管理员 → 403 + other_token = _tenant_admin_token(super_client, "other-admin", + "other-pass-1") + r = super_client.post(url, headers=_bearer(other_token), + files={"file": ("a.png", PNG_BYTES, "image/png")}) + assert r.status_code == 403 + + # 本租户管理员 → 200(require_tenant 放行租户全开) + demo_token = _tenant_admin_token(super_client, "demo-admin", + "demo-pass-1") + r = super_client.post(url, headers=_bearer(demo_token), + files={"file": ("a.png", PNG_BYTES, "image/png")}) + assert r.status_code == 200, r.text + + # 未知租户 → 404 + r = _upload(super_client, "nope", "a.png", PNG_BYTES, "image/png") + assert r.status_code == 404 + + +# ---- 解析链既有级行为回归(seed 段优先级在 uploads 之后) ---------------------------- + + +def test_seed_assets_still_served_for_file_tenants(app_client): + """文件源租户(jiancai)不上传时:uploads 段落空 → seed 资产照常服务 + (uploads 段的加入不改变既有两级链的行为)。""" + r = app_client.get("/api/public/avatar") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("image/") + assert r.content == (SEED_DIR / "jiancai" / "assets" / + "kefu-small.png").read_bytes()