feat(settings): 公司信息配置与 logo 管理(public 端点供登录页)
- system_settings 单行 CRUD:GET/PUT(AdminUser)+ GET /public(无鉴权, SettingPublicOut 白名单仅 company_name/logo_url 两字段,短缓存 300s) - logo 上传:后缀+magic bytes+2MB 三重校验,固定名覆盖落盘 uploads/logo/ (不使用上传方文件名,杜绝路径拼接);旧扩展名文件清理;文件流端点 no-cache,URL 带 update_time 版本参数破缓存 - logo 存目录不存库:图片入 DB 让每次读取与 mysqldump 放大传输且无法 独立替换;DB 只持一行相对路径 - 新增依赖 python-multipart(FastAPI UploadFile 必备传输层);.gitignore 增 backend/uploads/ 与 db/backups/ - 集成 11 例:单行语义/白名单字段/三重校验/越权 403/匿名 401/审计留痕 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
e95dc4b2de
commit
efed29647c
9 files changed
+433
No files matched your search
@@ -0,0 +1,137 @@
|
||||
"""系统设置(公司信息)接口测试。
|
||||
|
||||
守的是:
|
||||
- 单行语义:全系统恒一行,PUT 只改名称不增行
|
||||
- public 端点无鉴权可用但**白名单字段**——多一个敏感字段都是匿名泄漏
|
||||
- logo 上传三重校验(后缀/magic bytes/大小)——仅看后缀可被伪造
|
||||
- 审计留痕:系统设置属敏感配置,改必须有日志
|
||||
|
||||
真图片构造:PNG 用 8 字节签名 + 最小 IHDR;JPG 用 SOI 头 + 填充字节。
|
||||
仅校验 magic bytes 前缀,无需完整合法图片(解析由前端 img 完成)。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SETTINGS = "/api/v1/system/settings"
|
||||
|
||||
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
||||
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
|
||||
_TXT_BYTES = b"not an image at all"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestCompanyInfo:
|
||||
"""公司名称读取与更新。"""
|
||||
|
||||
def test_初始值与更新生效(self, client: TestClient, admin: dict) -> None:
|
||||
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
|
||||
assert got["company_name"]
|
||||
# logo_url 允许为 None(未上传)或历史遗留的 URL——单行库可复用,
|
||||
# 断言不依赖「干净库」前提;上传行为由 TestLogoUpload 专类覆盖
|
||||
assert got["logo_url"] is None or "/logo/public" in got["logo_url"]
|
||||
|
||||
new_name = f"测试公司_{uuid.uuid4().hex[:6]}"
|
||||
put = client.put(_SETTINGS, json={"company_name": new_name}, headers=admin["headers"])
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["data"]["company_name"] == new_name
|
||||
assert (
|
||||
client.get(_SETTINGS, headers=admin["headers"]).json()["data"]["company_name"]
|
||||
== new_name
|
||||
)
|
||||
|
||||
def test_名称为空被拒(self, client: TestClient, admin: dict) -> None:
|
||||
assert (
|
||||
client.put(_SETTINGS, json={"company_name": ""}, headers=admin["headers"]).status_code
|
||||
== 400
|
||||
)
|
||||
|
||||
def test_非管理员改名称被拒(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.put(_SETTINGS, json={"company_name": "越权公司"}, headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_修改留审计日志(self, client: TestClient, admin: dict) -> None:
|
||||
client.put(
|
||||
_SETTINGS,
|
||||
json={"company_name": f"审计公司_{uuid.uuid4().hex[:6]}"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
logs = client.get(
|
||||
"/api/v1/system/operation-logs",
|
||||
params={"module": "系统设置"},
|
||||
headers=admin["headers"],
|
||||
).json()["data"]["list"]
|
||||
assert logs, "修改公司名称未产生操作日志"
|
||||
|
||||
|
||||
class TestPublicEndpoint:
|
||||
"""匿名端点:白名单字段 + 可用性。"""
|
||||
|
||||
def test_无令牌可读且仅白名单字段(self, client: TestClient, admin: dict) -> None:
|
||||
name = f"公开公司_{uuid.uuid4().hex[:6]}"
|
||||
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
|
||||
|
||||
resp = client.get(f"{_SETTINGS}/public")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
|
||||
assert data["company_name"] == name
|
||||
assert resp.headers.get("cache-control") is not None
|
||||
|
||||
def test_写操作匿名不可用(self, client: TestClient) -> None:
|
||||
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
|
||||
401,
|
||||
405,
|
||||
}
|
||||
|
||||
|
||||
class TestLogoUpload:
|
||||
"""logo 上传校验链。"""
|
||||
|
||||
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
|
||||
return client.post(
|
||||
f"{_SETTINGS}/logo",
|
||||
files={"file": (filename, content, "application/octet-stream")},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
def test_合法png上传成功且回读(self, client: TestClient, admin: dict) -> None:
|
||||
resp = self._upload(client, admin, "logo.png", _PNG_BYTES)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["logo_url"] and "/logo/public" in out["logo_url"]
|
||||
|
||||
img = client.get(f"{_SETTINGS}/logo/public")
|
||||
assert img.status_code == 200
|
||||
assert img.headers["content-type"] == "image/png"
|
||||
|
||||
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
|
||||
resp = self._upload(client, admin, "fake.png", _TXT_BYTES)
|
||||
assert resp.status_code == 400
|
||||
assert "不符" in resp.json()["message"]
|
||||
|
||||
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
|
||||
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
|
||||
|
||||
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
|
||||
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
|
||||
resp = self._upload(client, admin, "big.png", big)
|
||||
assert resp.status_code == 400
|
||||
assert "2MB" in resp.json()["message"]
|
||||
|
||||
def test_匿名上传被拒(self, client: TestClient) -> None:
|
||||
resp = client.post(
|
||||
f"{_SETTINGS}/logo",
|
||||
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
Reference in new issue
Block a user