PayrollManagementSystem/backend/tests/integration/test_settings.py

331 lines
13 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)
_ASSET_KINDS = ("logo", "favicon")
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
"""快照上传目录里的真实图片(logo/favicon)。"""
from app.core.config import get_settings
root = get_settings().upload_dir
snapshot: dict[str, dict[str, bytes]] = {}
for kind in _ASSET_KINDS:
directory = root / kind
snapshot[kind] = (
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
if directory.is_dir()
else {}
)
return snapshot
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。
上传用例会用固定文件名覆盖 logo/favicon,并清理旧扩展名文件 —— 只还原
数据库路径而不还原文件,会把开发环境里真实上传的 logo 永久删掉。
"""
from app.core.config import get_settings
root = get_settings().upload_dir
for kind, files in snapshot.items():
directory = root / kind
directory.mkdir(parents=True, exist_ok=True)
for path in directory.glob("*"):
if path.is_file() and path.name not in files:
path.unlink(missing_ok=True)
for name, content in files.items():
(directory / name).write_bytes(content)
@pytest.fixture(autouse=True)
def _restore_company_info():
"""系统设置是**真实单行数据 + 真实上传文件**:用例前快照、结束后还原。
与其他表的测试数据不同,公司信息没有 t_ 前缀可供兜底清理 —— 不还原
的话,跑完测试后开发环境的侧边栏品牌/登录页/浏览器标题会被改成
「测试公司_xxx」,上传用例还会覆盖/删除真实 logo 与站点图标。
"""
from app.core.db import get_session_factory
from app.models import SystemSetting
factory = get_session_factory()
with factory() as session:
row = session.get(SystemSetting, 1)
snapshot = (
{column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
if row is not None
else None
)
asset_snapshot = _snapshot_assets()
yield
# 文件先还原:数据库路径随后回指同一批文件
_restore_assets(asset_snapshot)
if snapshot is None:
return
with factory() as session:
row = session.get(SystemSetting, 1)
if row is None:
return
for key, value in snapshot.items():
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
setattr(row, key, value)
session.commit()
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:
"""V8 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
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:
"""站点标题/品牌信息(V8):部分更新语义与空串清空。"""
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