Files
ai-kefu-admin/tests/test_avatar_upload.py
T

236 lines
10 KiB
Python
Raw Normal View History

"""头像上传与解析链(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/<slug>/<name> →
seed_profiles/<slug>/assets/<name> → 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/<slug>/<uuid>.<ext>(目录懒建),字节原样
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/<slug>/)
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()