PayrollManagementSystem/backend/tests/integration/test_settings.py
liuwenxin efed29647c 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>
2026-10-15 18:22:12 +08:00

138 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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