- 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>
138 lines
5.3 KiB
Python
138 lines
5.3 KiB
Python
"""系统设置(公司信息)接口测试。
|
||
|
||
守的是:
|
||
- 单行语义:全系统恒一行,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
|