PayrollManagementSystem/backend/tests/integration/test_settings.py
郑博文 ac4ba7514b merge: 集成客户评级弹窗、公司信息精简与参数中文化
将 feat/settings-rating-brand(基于 927592b)合并到最新 main(d03ddad):
- 冲突收口:系统设置服务/契约、集成测试夹具、布局/路由/登录页、客户列表
- 迁移重编号:本分支未发布的三个迁移压成 V11(客户评级 + 站点信息 + 版本号),
  down_revision 接到 main 的 90653814a421,db/migration 相应改为 V11
- 站点图标上传与 logo 上传统一为同步 def,超限先按 file.size 拦截
2026-10-20 11:42:49 +08:00

260 lines
11 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
_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32
_TXT_BYTES = b"not an image at all"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
# 对所有集成用例生效(不再只保护本模块)。
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",
"site_title",
"brand_slogan",
"footer_text",
"icp_no",
"favicon_url",
}, "public 端点字段越界"
assert data["company_name"] == name
assert resp.headers.get("cache-control") is not None
def test_已删除的档案字段不再返回(self, client: TestClient) -> None:
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
data = client.get(f"{_SETTINGS}/public").json()["data"]
for field in (
"system_subtitle",
"credit_code",
"company_address",
"contact_phone",
"contact_email",
):
assert field not in data, f"已删除字段仍出现在匿名端点:{field}"
def test_写操作匿名不可用(self, client: TestClient) -> None:
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
401,
405,
}
class TestCompanyProfile:
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None:
name = f"站点公司_{uuid.uuid4().hex[:6]}"
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"])
assert put.status_code == 200, put.text
assert put.json()["data"]["site_title"] == "测试站点标题"
# 未提交的 company_name 保持不变(部分更新语义)
assert put.json()["data"]["company_name"] == name
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
assert got["company_name"] == name
assert got["site_title"] == "测试站点标题"
def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None:
client.put(
_SETTINGS,
json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"},
headers=admin["headers"],
)
put = client.put(
_SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"]
)
assert put.status_code == 200, put.text
assert put.json()["data"]["brand_slogan"] is None
assert put.json()["data"]["icp_no"] is None
def test_超长字段被拒(self, client: TestClient, admin: dict) -> None:
resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"])
assert resp.status_code == 400, resp.text
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
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
"""同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。"""
first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"]
assert first and second
assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存"
class TestFaviconUpload:
"""站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。"""
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
return client.post(
f"{_SETTINGS}/favicon",
files={"file": (filename, content, "application/octet-stream")},
headers=admin["headers"],
)
def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["favicon_url"] and "/favicon/public" in out["favicon_url"]
img = client.get(f"{_SETTINGS}/favicon/public")
assert img.status_code == 200
assert img.headers["content-type"] == "image/x-icon"
def test_合法png上传成功(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "favicon.png", _PNG_BYTES)
assert resp.status_code == 200, resp.text
assert "/favicon/public" in resp.json()["data"]["favicon_url"]
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
resp = self._upload(client, admin, "fake.ico", _TXT_BYTES)
assert resp.status_code == 400, resp.text
assert "不符" in resp.json()["message"]
def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None:
first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"]
second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][
"favicon_url"
]
assert first and second
assert first != second, "同扩展名替换后站点图标 URL 未变化"
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}/favicon",
files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")},
)
assert resp.status_code == 401