签入版本
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request():
|
||||
"""模拟 FastAPI Request,仅提供 service 层用到的字段。"""
|
||||
class FR:
|
||||
state = types.SimpleNamespace(user_id=1, username="tester", realname="测试员")
|
||||
query_params = {}
|
||||
return FR()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""auth/service.py _record_fail:失败计数原子自增,达到上限触发锁定。"""
|
||||
import asyncio
|
||||
|
||||
import fakeredis.aioredis
|
||||
|
||||
import modules.auth.service as auth_svc
|
||||
from modules.auth.service import _record_fail
|
||||
|
||||
|
||||
async def _fake_incr_expire(redis, key, seconds):
|
||||
"""fakeredis 不支持 Lua eval,用等价非原子实现打桩真实脚本的语义。"""
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, seconds)
|
||||
return count
|
||||
|
||||
|
||||
def test_record_fail_uses_atomic_incr_and_locks_at_limit(monkeypatch):
|
||||
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 3)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
|
||||
|
||||
async def run():
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
fail_key = auth_svc._fail_key('1.2.3.4', 'tester')
|
||||
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
|
||||
|
||||
for _ in range(3):
|
||||
await _record_fail(redis, '1.2.3.4', 'tester')
|
||||
|
||||
assert int(await redis.get(fail_key)) == 3
|
||||
# 失败计数 key 首次创建即带 TTL,不会永久残留
|
||||
assert await redis.ttl(fail_key) > 0
|
||||
# 达到上限后设置锁定
|
||||
assert await redis.get(lock_key) == '1'
|
||||
assert await redis.ttl(lock_key) == 300
|
||||
await redis.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_record_fail_below_limit_no_lock(monkeypatch):
|
||||
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 5)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
|
||||
|
||||
async def run():
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
await _record_fail(redis, '1.2.3.4', 'tester')
|
||||
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
|
||||
assert await redis.get(lock_key) is None
|
||||
await redis.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""BaseService 分页/固定过滤钩子单元测试。
|
||||
|
||||
回归:_apply_page_filters 钩子须被 _build_page_query 调用,
|
||||
否则 dict/config 项按所属字典/配置的固定过滤会失效。
|
||||
"""
|
||||
import json
|
||||
|
||||
from modules.dictionary.dict.models import Dict
|
||||
from modules.dictionary.dict_item.models import DictItem
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict_item.service import dict_item_service
|
||||
from modules.configuration.config_item.repository import configitem_repo
|
||||
from modules.configuration.config_item.service import config_item_service
|
||||
from modules.system.level.repository import level_repo
|
||||
from modules.system.level.service import level_service
|
||||
from utils import dict_util
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_dict_item_page_filters_by_dict_id(fake_request, sqlite_db):
|
||||
dictitem_repo.create(name="性别男", value="1", dict_id=1, sort=1)
|
||||
dictitem_repo.create(name="性别女", value="2", dict_id=1, sort=2)
|
||||
dictitem_repo.create(name="状态启用", value="1", dict_id=2, sort=1)
|
||||
|
||||
fake_request.query_params = {"dict_id": 1}
|
||||
resp = dict_item_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert len(body["records"]) == 2
|
||||
assert all(row["dict_id"] == 1 for row in body["records"])
|
||||
|
||||
|
||||
def test_config_item_page_filters_by_config_id(fake_request, sqlite_db):
|
||||
configitem_repo.create(title="站点名", code="site_name", value="v", type="text", config_id=3, sort=1)
|
||||
configitem_repo.create(title="LOGO", code="logo", value="x", type="image", config_id=4, sort=1)
|
||||
|
||||
# type_name 已由数据字典驱动:种入 config_item_type 并清空进程缓存,避免取到空字典
|
||||
d = Dict(name="配置项类型", code="config_item_type", sort=1)
|
||||
sqlite_db.add(d)
|
||||
sqlite_db.flush()
|
||||
sqlite_db.add(DictItem(name="单行文本", value="text", dict_id=d.id, sort=1))
|
||||
sqlite_db.flush()
|
||||
dict_util._dict_cache.clear()
|
||||
|
||||
fake_request.query_params = {"config_id": 3}
|
||||
resp = config_item_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert len(body["records"]) == 1
|
||||
assert body["records"][0]["config_id"] == 3
|
||||
# 列表行序列化带 type_name,详情不含
|
||||
assert body["records"][0]["type_name"] == "单行文本"
|
||||
|
||||
|
||||
def test_page_like_eq_order(fake_request, sqlite_db):
|
||||
level_repo.create(name="初级", status=1, sort=3)
|
||||
level_repo.create(name="高级", status=1, sort=1)
|
||||
level_repo.create(name="资深", status=2, sort=2)
|
||||
|
||||
# 等值 status=1 + 排序 sort asc
|
||||
fake_request.query_params = {"status": 1}
|
||||
resp = level_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert [row["name"] for row in body["records"]] == ["高级", "初级"]
|
||||
|
||||
# 模糊 name 包含"级"(初级、高级匹配,资深不匹配)
|
||||
fake_request.query_params = {"name": "级"}
|
||||
resp = level_service.get_page(fake_request)
|
||||
assert _resp_body(resp)["data"]["total"] == 2
|
||||
|
||||
|
||||
def test_delete_success(fake_request, sqlite_db):
|
||||
obj = level_repo.create(name="待删除", status=1, sort=9)
|
||||
resp = level_service.delete(str(obj.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] == 0
|
||||
assert body["msg"] == "本次共删除1条数据"
|
||||
assert level_repo.get_by_id(obj.id) is None
|
||||
|
||||
|
||||
def test_delete_missing_id_failed(fake_request, sqlite_db):
|
||||
resp = level_service.delete("999")
|
||||
assert _resp_body(resp)["msg"] == "记录不存在"
|
||||
|
||||
|
||||
def test_delete_empty_ids_failed(fake_request, sqlite_db):
|
||||
resp = level_service.delete("")
|
||||
assert _resp_body(resp)["msg"] == "记录ID不存在"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""category service 单元测试(回归删除父级时禁止有子级)。"""
|
||||
import json
|
||||
|
||||
from modules.category import service as category_service
|
||||
from modules.category.models import Category
|
||||
from modules.category.repository import category_repo
|
||||
from modules.category.schemas import CategoryForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(categoryName="测试分类", categoryCode="C001", parentId=0, sort=1, note=None, type=1)
|
||||
data.update(overrides)
|
||||
return CategoryForm(**data)
|
||||
|
||||
|
||||
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
parent = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
category_service.add_category(
|
||||
fake_request,
|
||||
_form(categoryName="子分类", categoryCode="C001-1", parentId=parent.id),
|
||||
)
|
||||
child = category_repo.filter(Category.category_name == "子分类").first()
|
||||
|
||||
resp = category_service.delete_category(str(parent.id))
|
||||
assert _resp_body(resp)["code"] != 0
|
||||
# 父级与子级均保持生效
|
||||
assert category_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert category_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
leaf = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
resp = category_service.delete_category(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert category_repo.filter(Category.id == leaf.id).first().is_delete == 1
|
||||
|
||||
|
||||
# ============ 分类树成环检测:parent_id 存在性 + 自身/下级 校验 ============
|
||||
|
||||
def test_add_category_rejects_missing_parent(fake_request, sqlite_db):
|
||||
resp = category_service.add_category(fake_request, _form(parentId=9999))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "上级分类不存在" in body["msg"]
|
||||
|
||||
|
||||
def test_update_category_rejects_self_parent(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
cat = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
resp = category_service.update_category(fake_request, _form(id=cat.id, parentId=cat.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "自身" in body["msg"]
|
||||
# 原记录未被改动
|
||||
assert category_repo.get_by_id(cat.id).parent_id == 0
|
||||
|
||||
|
||||
def test_update_category_rejects_descendant_parent(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
parent = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
category_service.add_category(fake_request, _form(categoryName="子分类", parentId=parent.id))
|
||||
child = category_repo.filter(Category.category_name == "子分类").first()
|
||||
|
||||
# 把父级设为自己的子级 → 成环,拒绝
|
||||
resp = category_service.update_category(
|
||||
fake_request, _form(id=parent.id, categoryName="测试分类", parentId=child.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级" in body["msg"]
|
||||
assert category_repo.get_by_id(parent.id).parent_id == 0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""city service 单元测试(回归 haveChild 尾部逗号导致恒为元组/恒 truthy 的 bug)。"""
|
||||
import json
|
||||
|
||||
from modules.city import service as city_service
|
||||
from modules.city.models import City
|
||||
from modules.city.repository import city_repo
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_get_city_list_have_child_is_bool(fake_request, sqlite_db):
|
||||
city_repo.create(
|
||||
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
|
||||
level=1, parent_id=0, name="北京市", short_name="北京", full_name="北京市",
|
||||
pinyin="beijing", lng="116.4", lat="39.9",
|
||||
)
|
||||
fake_request.query_params = {"parent_id": 0}
|
||||
|
||||
resp = city_service.get_city_list(fake_request)
|
||||
data = _resp_body(resp)["data"]
|
||||
assert isinstance(data, list) and len(data) == 1
|
||||
assert data[0]["haveChild"] is True
|
||||
|
||||
|
||||
def test_get_city_list_leaf_have_child_false(fake_request, sqlite_db):
|
||||
city_repo.create(
|
||||
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
|
||||
level=4, parent_id=0, name="某街道", short_name="街道", full_name="某街道",
|
||||
pinyin="jiedao", lng="116.4", lat="39.9",
|
||||
)
|
||||
fake_request.query_params = {"parent_id": 0}
|
||||
|
||||
resp = city_service.get_city_list(fake_request)
|
||||
data = _resp_body(resp)["data"]
|
||||
assert isinstance(data, list) and len(data) == 1
|
||||
assert data[0]["haveChild"] is False
|
||||
@@ -0,0 +1,198 @@
|
||||
"""删除级联防护:存在外键引用时禁止删除(dept→user、category→article、city→子级、dict/config→item)。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core import R
|
||||
from core.base_service import BaseService
|
||||
from utils.request import parse_id_list
|
||||
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.system.dept.service import delete_dept
|
||||
from modules.system.role.models import Role, RoleMenu
|
||||
from modules.system.user.repository import user_repo
|
||||
from modules.category.repository import category_repo
|
||||
from modules.category.service import delete_category
|
||||
from modules.article.repository import article_repo
|
||||
from modules.city.repository import city_repo
|
||||
from modules.city.service import delete_city
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict.service import dict_service
|
||||
from modules.configuration.config_item.repository import configitem_repo
|
||||
from modules.configuration.config.service import config_service
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""模拟 SQLAlchemy Query:仅支持 first(),返回行或 None。"""
|
||||
|
||||
def __init__(self, has_row):
|
||||
self._has_row = has_row
|
||||
|
||||
def first(self):
|
||||
return object() if self._has_row else None
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
def _allow_delete(monkeypatch):
|
||||
"""放行批量删除,捕获传入的 ids 以便断言走到了该步骤。"""
|
||||
captured = {}
|
||||
|
||||
def fake_batch_delete(repo, ids):
|
||||
captured['ids'] = ids
|
||||
return R.ok(msg="本次共删除{0}条数据".format(len(parse_id_list(str(ids)))))
|
||||
|
||||
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
|
||||
return captured
|
||||
|
||||
|
||||
# ============ dept → user ============
|
||||
|
||||
def test_delete_dept_blocks_when_users_reference(monkeypatch):
|
||||
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级部门
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有用户引用
|
||||
result = delete_dept('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '用户' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_dept_allows_when_no_references(monkeypatch):
|
||||
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_dept('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ category → article ============
|
||||
|
||||
def test_delete_category_blocks_when_articles_reference(monkeypatch):
|
||||
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级分类
|
||||
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有文章引用
|
||||
result = delete_category('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '文章' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_category_allows_when_no_references(monkeypatch):
|
||||
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_category('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ city → 子级 ============
|
||||
|
||||
def test_delete_city_blocks_when_child_exists(monkeypatch):
|
||||
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
result = delete_city('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '下级' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_city_allows_when_no_children(monkeypatch):
|
||||
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_city('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ dict → dict_item ============
|
||||
|
||||
def test_dict_before_delete_blocks_when_items_exist(monkeypatch):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = dict_service._before_delete('1,2')
|
||||
assert err is not None
|
||||
assert '字典项' in err
|
||||
|
||||
|
||||
def test_dict_before_delete_allows_when_no_items(monkeypatch):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert dict_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
# ============ config → config_item ============
|
||||
|
||||
def test_config_before_delete_blocks_when_items_exist(monkeypatch):
|
||||
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = config_service._before_delete('1,2')
|
||||
assert err is not None
|
||||
assert '配置项' in err
|
||||
|
||||
|
||||
def test_config_before_delete_allows_when_no_items(monkeypatch):
|
||||
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert config_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
# ============ role → user_role / role_menu 残留清理 ============
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_role_cleans_user_role_and_role_menu(sqlite_db, monkeypatch):
|
||||
from modules.system.role import service as role_service
|
||||
from modules.system.role.models import Role
|
||||
from modules.system.role_menu.models import RoleMenu
|
||||
from modules.system.user_role.models import UserRole
|
||||
from core.database import get_db_session
|
||||
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(role_service, 'invalidate_role_perms', _noop)
|
||||
|
||||
# 显式非内置 id(内置角色 1-4 禁止删除)
|
||||
role = Role(id=100, name='普通角色', code='R1', status=1, sort=1)
|
||||
role.save()
|
||||
db = get_db_session()
|
||||
db.add_all([
|
||||
UserRole(user_id=5, role_id=role.id),
|
||||
RoleMenu(role_id=role.id, menu_id=10),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
resp = await role_service.delete_role(str(role.id))
|
||||
assert _body(resp)['code'] == 0
|
||||
# 关联残留已清理,角色本身软删
|
||||
assert db.query(UserRole).filter(UserRole.role_id == role.id).count() == 0
|
||||
assert db.query(RoleMenu).filter(RoleMenu.role_id == role.id).count() == 0
|
||||
assert role.is_delete == 1
|
||||
|
||||
|
||||
# ============ position / level → user 引用(与部门一致) ============
|
||||
|
||||
def test_position_before_delete_blocks_when_users_reference(monkeypatch):
|
||||
from modules.system.position.service import position_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = position_service._before_delete('1')
|
||||
assert err is not None
|
||||
assert '岗位' in err
|
||||
|
||||
|
||||
def test_position_before_delete_allows_when_no_references(monkeypatch):
|
||||
from modules.system.position.service import position_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert position_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
def test_level_before_delete_blocks_when_users_reference(monkeypatch):
|
||||
from modules.system.level.service import level_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = level_service._before_delete('1')
|
||||
assert err is not None
|
||||
assert '职级' in err
|
||||
|
||||
|
||||
def test_level_before_delete_allows_when_no_references(monkeypatch):
|
||||
from modules.system.level.service import level_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert level_service._before_delete('1,2') is None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""system/dept service 单元测试(回归删除父级时禁止有子级)。"""
|
||||
import json
|
||||
|
||||
from modules.system.dept import service as dept_service
|
||||
from modules.system.dept.models import Dept
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.system.dept.schemas import DeptForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(name="总公司", code="HQ", type=1, parentId=None, sort=1, note=None)
|
||||
data.update(overrides)
|
||||
return DeptForm(**data)
|
||||
|
||||
|
||||
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
dept_service.add_dept(fake_request, _form())
|
||||
parent = dept_repo.filter(Dept.name == "总公司").first()
|
||||
|
||||
dept_service.add_dept(
|
||||
fake_request,
|
||||
_form(name="研发部", code="RD", parentId=parent.id),
|
||||
)
|
||||
child = dept_repo.filter(Dept.name == "研发部").first()
|
||||
|
||||
resp = dept_service.delete_dept(str(parent.id))
|
||||
assert _resp_body(resp)["code"] != 0
|
||||
assert dept_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert dept_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
dept_service.add_dept(fake_request, _form())
|
||||
leaf = dept_repo.filter(Dept.name == "总公司").first()
|
||||
|
||||
resp = dept_service.delete_dept(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert dept_repo.filter(Dept.id == leaf.id).first().is_delete == 1
|
||||
@@ -0,0 +1,101 @@
|
||||
"""字典项唯一性按所属字典隔离:同一字典下名称不可重复,不同字典同名放行。"""
|
||||
import json
|
||||
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict_item.schemas import DictItemForm
|
||||
from modules.dictionary.dict_item.service import dict_item_service
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""模拟 SQLAlchemy Query:记录是否调用 filter(排除自身),first() 返回行或 None。"""
|
||||
|
||||
def __init__(self, has_row=True):
|
||||
self._has_row = has_row
|
||||
self.filter_called = False
|
||||
|
||||
def filter(self, *criteria):
|
||||
self.filter_called = True
|
||||
# 排除自身后应无重复行
|
||||
return _FakeQuery(False)
|
||||
|
||||
def first(self):
|
||||
return object() if self._has_row else None
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
def _set_filter_by(monkeypatch, query):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', lambda **kw: query)
|
||||
|
||||
|
||||
# ============ 查重范围:按 (dict_id, name) 隔离 ============
|
||||
|
||||
def test_dict_item_unique_scoped_to_dict(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_filter_by(**kw):
|
||||
captured.update(kw)
|
||||
return _FakeQuery(False)
|
||||
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
assert dict_item_service._check_unique(data) is None
|
||||
# 查重条件是 dict_id + name,而非仅 name 全局查重
|
||||
assert captured == {'dict_id': 2, 'name': '启用'}
|
||||
|
||||
|
||||
def test_dict_item_unique_blocks_same_name_in_same_dict(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_filter_by(**kw):
|
||||
captured.update(kw)
|
||||
return _FakeQuery(True)
|
||||
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
|
||||
assert captured == {'dict_id': 2, 'name': '启用'}
|
||||
|
||||
|
||||
# ============ 编辑时排除自身 ============
|
||||
|
||||
def test_dict_item_unique_excludes_self_on_update(monkeypatch):
|
||||
query = _FakeQuery(True)
|
||||
_set_filter_by(monkeypatch, query)
|
||||
data = DictItemForm(id=5, name='启用', value='1', dictId=2, sort=1)
|
||||
# 排除自身后无重复 → 放行
|
||||
assert dict_item_service._check_unique(data, exclude_id=5) is None
|
||||
assert query.filter_called is True
|
||||
|
||||
|
||||
def test_dict_item_unique_add_does_not_exclude(monkeypatch):
|
||||
query = _FakeQuery(True)
|
||||
_set_filter_by(monkeypatch, query)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
# 新增不排除自身 → 查到同名字段即判重
|
||||
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
|
||||
assert query.filter_called is False
|
||||
|
||||
|
||||
# ============ add 端到端:不同字典同名可新增,同字典同名被拒 ============
|
||||
|
||||
def test_dict_item_add_allows_same_name_in_different_dict(monkeypatch, fake_request):
|
||||
_set_filter_by(monkeypatch, _FakeQuery(False))
|
||||
captured = {}
|
||||
monkeypatch.setattr(dictitem_repo, 'create', lambda **fields: captured.update(fields) or object())
|
||||
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
result = dict_item_service.add(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['dictId'] == 2
|
||||
|
||||
|
||||
def test_dict_item_add_blocks_same_name_in_same_dict(monkeypatch, fake_request):
|
||||
_set_filter_by(monkeypatch, _FakeQuery(True))
|
||||
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
result = dict_item_service.add(fake_request, data)
|
||||
assert _body(result)['ok'] is False
|
||||
assert '重复' in _body(result)['msg']
|
||||
@@ -0,0 +1,102 @@
|
||||
"""dict_util 字典读取工具:列表/映射/单值读取,进程内缓存与失效行为。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from utils import dict_util
|
||||
from utils.dict_util import (
|
||||
get_dict_items,
|
||||
get_dict_label,
|
||||
get_dict_map,
|
||||
invalidate_dict,
|
||||
invalidate_dicts_by_ids,
|
||||
)
|
||||
|
||||
|
||||
def _clear_cache():
|
||||
dict_util._dict_cache.clear()
|
||||
|
||||
|
||||
def _rows():
|
||||
# 刻意乱序:女(sort=5)、男(sort=1)、保密(sort=10),校验按 sort+id 升序输出
|
||||
return [
|
||||
SimpleNamespace(id=2, name='女', value='2', sort=5),
|
||||
SimpleNamespace(id=1, name='男', value='1', sort=1),
|
||||
SimpleNamespace(id=3, name='保密', value='3', sort=10),
|
||||
]
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
def _patch_repo(monkeypatch, dict_obj):
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: dict_obj)
|
||||
monkeypatch.setattr(dict_util.dictitem_repo, 'filter_by', lambda **kw: _FakeQuery(_rows()))
|
||||
|
||||
|
||||
# ============ 读取 ============
|
||||
|
||||
def test_get_dict_map_maps_value_to_label(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
assert get_dict_map('gender') == {'1': '男', '2': '女', '3': '保密'}
|
||||
|
||||
|
||||
def test_get_dict_items_sorted_by_sort_then_id(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
assert [it['value'] for it in get_dict_items('gender')] == ['1', '2', '3']
|
||||
|
||||
|
||||
def test_get_dict_label_normalizes_value_to_str(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
# 用户存整数 1,字典项 value 是字符串 '1',需自动转字符串匹配
|
||||
assert get_dict_label('gender', 1) == '男'
|
||||
assert get_dict_label('gender', '2') == '女'
|
||||
assert get_dict_label('gender', 99) is None
|
||||
assert get_dict_label('gender', 99, '未知') == '未知'
|
||||
|
||||
|
||||
def test_get_dict_map_unknown_code_returns_empty(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, None)
|
||||
assert get_dict_items('nope') == []
|
||||
assert get_dict_map('nope') == {}
|
||||
|
||||
|
||||
# ============ 缓存 ============
|
||||
|
||||
def test_cache_hit_skips_db(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender')
|
||||
calls = []
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: calls.append(1) or None)
|
||||
get_dict_map('gender') # 命中缓存,不再访问 DB
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_invalidate_dict_clears_cache(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender')
|
||||
invalidate_dict('gender')
|
||||
assert dict_util._dict_cache == {}
|
||||
|
||||
|
||||
def test_invalidate_dicts_by_ids_clears_cached_codes(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender') # 先填充缓存
|
||||
|
||||
class _FakeRows:
|
||||
def all(self):
|
||||
return [SimpleNamespace(code='gender')]
|
||||
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'filter', lambda *criteria: _FakeRows())
|
||||
invalidate_dicts_by_ids({1})
|
||||
assert dict_util._dict_cache == {}
|
||||
@@ -0,0 +1,301 @@
|
||||
"""调度器安全相关测试:SSRF 防护 + 单实例防护 + 统一线程池。"""
|
||||
from modules.job import scheduler as sch
|
||||
|
||||
|
||||
def test_build_full_url():
|
||||
assert sch.build_full_url('/api/x') == f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/x"
|
||||
assert sch.build_full_url('http://a.com/x') == 'http://a.com/x'
|
||||
assert sch.build_full_url('ftp://a.com/x') == 'ftp://a.com/x'
|
||||
|
||||
|
||||
def test_validate_http_url_rejects_internal_ips():
|
||||
for url in (
|
||||
'http://127.0.0.1:6379/', # 回环
|
||||
'http://10.0.0.1/x', # 私有A类
|
||||
'http://169.254.169.254/latest', # 云元数据
|
||||
'http://192.168.1.1/x', # 私有C类
|
||||
'http://172.16.5.5/x', # 私有B类
|
||||
):
|
||||
ok, reason = sch._validate_http_url(url)
|
||||
assert ok is False, url
|
||||
assert '禁止' in reason or '内网' in reason
|
||||
|
||||
|
||||
def test_validate_http_url_rejects_non_http_scheme():
|
||||
for url in ('ftp://a.com/x', 'file:///etc/passwd', 'javascript:alert(1)'):
|
||||
ok, reason = sch._validate_http_url(url)
|
||||
assert ok is False, url
|
||||
assert '协议' in reason
|
||||
|
||||
|
||||
def test_validate_http_url_accepts_public_ip():
|
||||
# 公网 IP 字面量,无需真实 DNS
|
||||
ok, reason = sch._validate_http_url('http://93.184.216.34/')
|
||||
assert ok is True, reason
|
||||
|
||||
|
||||
def test_execute_by_url_blocks_internal_url(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
sch, '_send_sync_request',
|
||||
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
|
||||
result = sch._execute_by_url(1, 'http://127.0.0.1:6379', 1)
|
||||
assert result['success'] is False
|
||||
assert '禁止' in result['error'] or '内网' in result['error']
|
||||
assert sent == [] # 未实际发起请求
|
||||
|
||||
|
||||
def test_execute_by_url_relative_self_call_allowed(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
sch, '_send_sync_request',
|
||||
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
|
||||
result = sch._execute_by_url(1, '/api/ping', 1)
|
||||
assert result['success'] is True
|
||||
assert sent == [f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/ping"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SSRF DNS 重绑定防护:预解析后固定 IP 直连,杜绝二次解析域名
|
||||
# ============================================================
|
||||
|
||||
def test_resolve_and_pin_returns_pinned_ip():
|
||||
# 公网 IP 字面量,无需真实 DNS
|
||||
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34/x')
|
||||
assert ok is True, reason
|
||||
assert ip == '93.184.216.34'
|
||||
|
||||
|
||||
def test_resolve_and_pin_rejects_custom_port():
|
||||
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34:8080/x')
|
||||
assert ok is False
|
||||
assert '端口' in reason
|
||||
assert ip is None
|
||||
|
||||
|
||||
def test_resolve_and_pin_allows_80_443():
|
||||
for url in ('http://93.184.216.34:80/x', 'https://93.184.216.34:443/x'):
|
||||
ok, reason, ip = sch._resolve_and_pin(url)
|
||||
assert ok is True, reason
|
||||
assert ip == '93.184.216.34'
|
||||
|
||||
|
||||
def test_pinned_connection_pins_dns_host_keeps_real_host():
|
||||
# HTTP 与 HTTPS 连接:_dns_host 固定为预校验 IP(实际建连目标),
|
||||
# host 保持真实主机名(Host 头 / TLS SNI)——同域名只解析一次,杜绝重绑定。
|
||||
for cls, port in ((sch._PinnedHTTPConnection, 80), (sch._PinnedHTTPSConnection, 443)):
|
||||
conn = cls(host='example.com', port=port, pinned_ip='93.184.216.34')
|
||||
assert conn._dns_host == '93.184.216.34'
|
||||
assert conn.host == 'example.com'
|
||||
|
||||
|
||||
def test_send_sync_request_uses_pinned_ip_when_given(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {'ok': 1}
|
||||
|
||||
monkeypatch.setattr(
|
||||
sch, '_post_pinned',
|
||||
lambda url, data, headers, timeout, pinned_ip: calls.append((url, pinned_ip)) or _FakeResp())
|
||||
result = sch._send_sync_request('http://example.com/x', {'a': 1}, 1, '93.184.216.34')
|
||||
assert result['success'] is True
|
||||
assert calls == [('http://example.com/x', '93.184.216.34')]
|
||||
|
||||
|
||||
def test_send_sync_request_without_pinned_ip_uses_plain_post(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {'ok': 1}
|
||||
|
||||
monkeypatch.setattr(
|
||||
sch, '_post_pinned',
|
||||
lambda *a, **k: calls.append('pinned') or _FakeResp())
|
||||
monkeypatch.setattr(
|
||||
sch.requests, 'post',
|
||||
lambda url, **kw: calls.append('plain') or _FakeResp())
|
||||
|
||||
# 相对路径自调用等可信场景无固定 IP,应走常规 requests.post 而非 pinned 路径
|
||||
result = sch._send_sync_request('http://127.0.0.1:8031/api/ping', {'a': 1}, 1)
|
||||
assert result['success'] is True
|
||||
assert calls == ['plain']
|
||||
|
||||
|
||||
def test_job_single_instance_claim(monkeypatch):
|
||||
# 禁用 Redis 锁(退化进程内互斥),使进程内 set 行为可独立验证
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
|
||||
assert sch._try_claim_job(42) is True
|
||||
assert sch._try_claim_job(42) is False # 已占用,拒绝重入
|
||||
sch._release_job(42)
|
||||
assert sch._try_claim_job(42) is True
|
||||
sch._release_job(42)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Redis 分布式锁:跨 worker 进程单实例互斥
|
||||
# ============================================================
|
||||
|
||||
def test_try_claim_job_respects_cross_process_redis_lock(monkeypatch):
|
||||
import fakeredis
|
||||
|
||||
fake = fakeredis.FakeRedis()
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: fake)
|
||||
|
||||
# 进程内重复触发仍被本地 set 拦截
|
||||
assert sch._try_claim_job(7) is True
|
||||
assert sch._try_claim_job(7) is False
|
||||
sch._release_job(7)
|
||||
assert sch._try_claim_job(7) is True
|
||||
sch._release_job(7)
|
||||
|
||||
# 模拟他进程已持锁:Redis 锁被占用 → 本进程拒绝,且本地占用已回滚
|
||||
fake.set(f'{sch._JOB_LOCK_PREFIX}9', '1', nx=True, ex=300)
|
||||
assert sch._try_claim_job(9) is False
|
||||
assert 9 not in sch._running_jobs
|
||||
# 释放后(他进程删除锁)可再次占用
|
||||
fake.delete(f'{sch._JOB_LOCK_PREFIX}9')
|
||||
assert sch._try_claim_job(9) is True
|
||||
sch._release_job(9)
|
||||
|
||||
|
||||
def test_try_claim_job_degrades_when_redis_unavailable(monkeypatch):
|
||||
# Redis 不可用 → 退化进程内互斥,不中断任务执行
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
|
||||
assert sch._try_claim_job(10) is True
|
||||
assert sch._try_claim_job(10) is False # 本地 set 仍生效
|
||||
sch._release_job(10)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _handle_scheduler_update:运行中任务编辑后必须重加调度器刷新执行参数
|
||||
# ============================================================
|
||||
|
||||
def _patch_scheduler_calls(monkeypatch):
|
||||
"""打桩 job.service 中的调度器函数,记录 add/remove 调用。"""
|
||||
from modules.job import service as job_svc
|
||||
calls = {'add': [], 'remove': []}
|
||||
monkeypatch.setattr(
|
||||
job_svc, 'add_job_to_scheduler',
|
||||
lambda data: calls['add'].append(data) or True)
|
||||
monkeypatch.setattr(
|
||||
job_svc, 'remove_job_from_scheduler',
|
||||
lambda jid: calls['remove'].append(jid) or True)
|
||||
return job_svc, calls
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_edit_readds(monkeypatch):
|
||||
"""运行中任务编辑 url/params:cron 未变也需重加,否则调度器仍按旧参数执行。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'url': '/api/new'}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['remove'] == []
|
||||
assert len(calls['add']) == 1
|
||||
# 重加时携带完整任务状态与有效 cron(部分更新缺 cron 时沿用旧值)
|
||||
added = calls['add'][0]
|
||||
assert added['id'] == 5
|
||||
assert added['url'] == '/api/new'
|
||||
assert added['cron_expression'] == '0 * * * *'
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_explicit_new_cron(monkeypatch):
|
||||
"""运行中任务显式改 cron:以新 cron 重加。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'cron_expression': '*/5 * * * *'}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['add'][0]['cron_expression'] == '*/5 * * * *'
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_to_disabled_removes(monkeypatch):
|
||||
"""运行中 → 禁用:从调度器移除。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 2}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == [5]
|
||||
|
||||
|
||||
def test_handle_scheduler_update_disabled_to_running_readds(monkeypatch):
|
||||
"""暂停 → 启用且带 cron:重加。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'cron_expression': '0 * * * *'}, old_status=2, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert len(calls['add']) == 1
|
||||
assert calls['remove'] == []
|
||||
|
||||
|
||||
def test_handle_scheduler_update_disabled_to_running_without_cron(monkeypatch):
|
||||
"""暂停 → 启用但无 cron:无法调度,移除(保持调度器一致性)。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1}, old_status=2, old_cron=None)
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == [5]
|
||||
|
||||
|
||||
def test_handle_scheduler_update_no_status_change_noop(monkeypatch):
|
||||
"""保持暂停:不触发任何调度器操作。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 2}, old_status=2, old_cron=None)
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_cron_trigger:5/6 字段 cron,6 字段首字段为秒(秒级触发)
|
||||
# ============================================================
|
||||
|
||||
def _fire_seconds_away(cron_expr):
|
||||
"""返回 (next_fire, 距 now 的秒数)。now 取本地 aware 时间,与触发器默认时区对齐。"""
|
||||
from datetime import datetime
|
||||
now = datetime.now().astimezone()
|
||||
nf = sch._build_cron_trigger(cron_expr).get_next_fire_time(None, now)
|
||||
return nf, (nf - now).total_seconds() if nf else None
|
||||
|
||||
|
||||
def test_build_cron_trigger_5_fields_minute_granularity():
|
||||
nf, delta = _fire_seconds_away('0 * * * *') # 每小时整点
|
||||
assert nf is not None
|
||||
assert 0 < delta <= 3600
|
||||
|
||||
|
||||
def test_build_cron_trigger_6_fields_second_granularity():
|
||||
nf, delta = _fire_seconds_away('*/15 * * * * *') # 每 15 秒
|
||||
assert nf is not None
|
||||
assert 0 < delta <= 15, f"秒级 cron 应 15s 内触发,实际 {delta}s"
|
||||
|
||||
|
||||
def test_build_cron_trigger_5_field_matches_from_crontab():
|
||||
"""5 字段行为与 CronTrigger.from_crontab 完全一致(向后兼容)。"""
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from datetime import datetime
|
||||
now = datetime.now().astimezone()
|
||||
t1 = sch._build_cron_trigger('0 * * * *').get_next_fire_time(None, now)
|
||||
t2 = CronTrigger.from_crontab('0 * * * *').get_next_fire_time(None, now)
|
||||
assert t1 == t2
|
||||
|
||||
|
||||
def test_build_cron_trigger_invalid_field_count_raises():
|
||||
import pytest
|
||||
for bad in ('', '* * * *', '* * * * * * *'): # 0/4/7 字段均非法
|
||||
with pytest.raises(ValueError):
|
||||
sch._build_cron_trigger(bad)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""modules/job/service.py 暂停/恢复任务:update_user 应取当前操作人,而非创建人。"""
|
||||
import json
|
||||
import types
|
||||
|
||||
from modules.job import service as job_service
|
||||
from modules.job.models import Job
|
||||
|
||||
|
||||
def _make_request(user_id=5, realname='测试用户'):
|
||||
req = types.SimpleNamespace()
|
||||
req.state = types.SimpleNamespace(user_id=user_id, realname=realname)
|
||||
return req
|
||||
|
||||
|
||||
def _make_job(sqlite_db, status=1, create_user=1):
|
||||
job = Job(job_name='任务A', status=status, cron_expression='0 * * * *',
|
||||
execute_policy=1, is_sync=0, create_user=create_user)
|
||||
job.save()
|
||||
sqlite_db.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_pause_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
|
||||
monkeypatch.setattr(job_service, 'pause_job_in_scheduler', lambda jid: True)
|
||||
job = _make_job(sqlite_db, status=1, create_user=1)
|
||||
|
||||
resp = job_service.pause_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
|
||||
assert _body(resp)['code'] == 0
|
||||
assert job.status == 2
|
||||
# 更新人 = 操作人姓名(张三),而非创建人(1)
|
||||
assert job.update_user == '张三'
|
||||
|
||||
|
||||
def test_resume_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
|
||||
monkeypatch.setattr(job_service, 'add_job_to_scheduler', lambda data: True)
|
||||
job = _make_job(sqlite_db, status=2, create_user=1)
|
||||
|
||||
resp = job_service.resume_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
|
||||
assert _body(resp)['code'] == 0
|
||||
assert job.status == 1
|
||||
assert job.update_user == '张三'
|
||||
@@ -0,0 +1,163 @@
|
||||
"""system/menu service 单元测试(async 路径,回归删除父级时禁止有子级)。
|
||||
|
||||
测试环境无 Redis:monkeypatch 掉 invalidate_menu_change,避免 get_redis() 抛异常。
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.system.menu import service as menu_service
|
||||
from modules.system.menu.models import Menu
|
||||
from modules.system.menu.repository import menu_repo
|
||||
from modules.system.menu.schemas import MenuForm
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_perm_cache(monkeypatch):
|
||||
async def _noop():
|
||||
return None
|
||||
monkeypatch.setattr(menu_service, "invalidate_menu_change", _noop)
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(
|
||||
name="系统管理", icon=None, path="/system", component=None, parentId=0,
|
||||
type=0, target=0, permission=None, hide=0, status=1, sort=1, note=None,
|
||||
)
|
||||
data.update(overrides)
|
||||
return MenuForm(**data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form())
|
||||
parent = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
await menu_service.add_menu(
|
||||
fake_request,
|
||||
_form(name="用户管理", path="/system/user", parentId=parent.id),
|
||||
)
|
||||
child = menu_repo.filter(Menu.name == "用户管理").first()
|
||||
|
||||
resp = await menu_service.delete_menu(str(parent.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级菜单" in body["msg"]
|
||||
assert menu_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert menu_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="单菜单", path="/single"))
|
||||
leaf = menu_repo.filter(Menu.name == "单菜单").first()
|
||||
|
||||
resp = await menu_service.delete_menu(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert menu_repo.filter(Menu.id == leaf.id).first().is_delete == 1
|
||||
|
||||
|
||||
# ============ 菜单树成环检测:update 的 parent_id 存在性 + 自身/下级 校验 ============
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_missing_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
menu = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=99999))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "上级菜单不存在" in body["msg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_self_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
menu = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=menu.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "自身" in body["msg"]
|
||||
# 原记录未被改动
|
||||
assert menu_repo.get_by_id(menu.id).parent_id == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_descendant_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
parent = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
await menu_service.add_menu(
|
||||
fake_request, _form(name="用户管理", path="/system/user", parentId=parent.id))
|
||||
child = menu_repo.filter(Menu.name == "用户管理").first()
|
||||
|
||||
# 把父级设为自己的子级 → 成环,拒绝
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=parent.id, parentId=child.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级" in body["msg"]
|
||||
assert menu_repo.get_by_id(parent.id).parent_id == 0
|
||||
|
||||
|
||||
# ============ 多角色重复授权:权限列表 / 菜单树 distinct 去重 ============
|
||||
|
||||
def _seed_user_with_two_roles_same_menu(sqlite_db):
|
||||
"""造一个同时属于两个角色、且两角色均授予同一权限节点/菜单的用户。
|
||||
|
||||
返回的用户 id 必不为 1(先占位超管),否则会走超级管理员全量放行分支。
|
||||
"""
|
||||
from modules.system.role.models import Role
|
||||
from modules.system.role_menu.models import RoleMenu
|
||||
from modules.system.user.models import User
|
||||
from modules.system.user_role.models import UserRole
|
||||
|
||||
User(realname='超管', nickname='admin', avatar='', mobile='', email='',
|
||||
province_code='', city_code='', district_code='', street_code='', address='',
|
||||
username='admin', password='', status=1).save()
|
||||
|
||||
user = User(realname='张三', nickname='zhangsan', avatar='', mobile='', email='',
|
||||
province_code='', city_code='', district_code='', street_code='', address='',
|
||||
username='zhangsan', password='', status=1)
|
||||
user.save()
|
||||
|
||||
r1 = Role(name='角色一', code='R1', status=1, sort=1); r1.save()
|
||||
r2 = Role(name='角色二', code='R2', status=1, sort=1); r2.save()
|
||||
|
||||
perm = Menu(name='添加用户', parent_id=0, path='/system/user/add', type=1,
|
||||
permission='sys:user:add', status=0, sort=1)
|
||||
perm.save()
|
||||
menu = Menu(name='用户管理', parent_id=0, path='/system/user', component='system/user/index',
|
||||
type=0, status=0, hide=0, sort=1)
|
||||
menu.save()
|
||||
|
||||
# 两个角色授予同一权限节点与同一菜单 → 多角色 join 会产生重复行
|
||||
for role in (r1, r2):
|
||||
RoleMenu(role_id=role.id, menu_id=perm.id).save()
|
||||
RoleMenu(role_id=role.id, menu_id=menu.id).save()
|
||||
UserRole(user_id=user.id, role_id=role.id).save()
|
||||
|
||||
sqlite_db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def test_permissions_list_dedup_for_multi_role(sqlite_db):
|
||||
user = _seed_user_with_two_roles_same_menu(sqlite_db)
|
||||
perms = menu_service.get_permissions_list_sync(user.id)
|
||||
# 同一权限节点经两个角色授权只出现一次
|
||||
assert perms.count('sys:user:add') == 1
|
||||
assert len(perms) == len(set(perms))
|
||||
|
||||
|
||||
def test_menu_list_dedup_for_multi_role(sqlite_db):
|
||||
user = _seed_user_with_two_roles_same_menu(sqlite_db)
|
||||
tree = menu_service.get_permission_menu_list_sync(user.id)
|
||||
# 菜单树无重复节点
|
||||
assert len(tree) == 1
|
||||
assert tree[0]['name'] == '用户管理'
|
||||
@@ -0,0 +1,44 @@
|
||||
"""notice service 单元测试(回归富文本 content 空值保护)。"""
|
||||
import json
|
||||
|
||||
from modules.notice.service import notice_service
|
||||
from modules.notice.models import Notice
|
||||
from modules.notice.repository import notice_repo
|
||||
from modules.notice.schemas import NoticeForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(title="测试通知", source=1, url="https://example.com", status=1, isTop=1, click=0)
|
||||
data.update(overrides)
|
||||
return NoticeForm(**data)
|
||||
|
||||
|
||||
def test_add_notice_content_none_saves_null(fake_request, sqlite_db):
|
||||
resp = notice_service.add(fake_request, _form(content=None))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
row = notice_repo.filter(Notice.title == "测试通知").first()
|
||||
assert row is not None
|
||||
assert row.content is None
|
||||
|
||||
|
||||
def test_add_notice_plain_text_stored_as_is(fake_request, sqlite_db):
|
||||
resp = notice_service.add(fake_request, _form(title="纯文本通知", content="hello world"))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
row = notice_repo.filter(Notice.title == "纯文本通知").first()
|
||||
assert row.content == "hello world"
|
||||
|
||||
|
||||
def test_update_notice_content_none_keeps_old(fake_request, sqlite_db):
|
||||
notice_service.add(fake_request, _form(title="保留旧内容", content="old-content"))
|
||||
row = notice_repo.filter(Notice.title == "保留旧内容").first()
|
||||
|
||||
resp = notice_service.update(
|
||||
fake_request,
|
||||
_form(title="保留旧内容", content=None, id=row.id),
|
||||
)
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert notice_repo.get_by_id(row.id).content == "old-content"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""shared/base_repository 与模块 repository 的单元测试(SQLite 内存库)。"""
|
||||
|
||||
from modules.system.level.models import Level
|
||||
from modules.system.level.repository import level_repo
|
||||
from modules.system.position.repository import position_repo
|
||||
|
||||
|
||||
def test_level_repo_crud(sqlite_db):
|
||||
# create
|
||||
obj = level_repo.create(name="高级", status=1, sort=1)
|
||||
assert obj.id is not None
|
||||
sqlite_db.commit()
|
||||
|
||||
# get / exists
|
||||
assert level_repo.get_by_id(obj.id).name == "高级"
|
||||
assert level_repo.exists(obj.id)
|
||||
assert level_repo.get_by_id(0) is None
|
||||
assert not level_repo.exists(999)
|
||||
|
||||
# paginate
|
||||
items, total = level_repo.paginate(1, 10)
|
||||
assert total == 1
|
||||
assert items[0].id == obj.id
|
||||
|
||||
# update
|
||||
assert level_repo.update(obj.id, {"name": "资深"}) == 1
|
||||
assert level_repo.get_by_id(obj.id).name == "资深"
|
||||
|
||||
# batch soft delete(数据原语,返回删除条数)
|
||||
assert level_repo.batch_delete(str(obj.id)) == 1
|
||||
assert level_repo.get_by_id(obj.id) is None
|
||||
|
||||
|
||||
def test_position_repo_paginate_empty(sqlite_db):
|
||||
items, total = position_repo.paginate(1, 10)
|
||||
assert total == 0
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_repo_query_conveniences(sqlite_db):
|
||||
level_repo.create(name="甲", status=1, sort=1)
|
||||
level_repo.create(name="乙", status=1, sort=2)
|
||||
level_repo.create(name="丙", status=2, sort=3)
|
||||
|
||||
# exists_by_field / 排除自身
|
||||
assert level_repo.exists_by_field("name", "甲")
|
||||
assert not level_repo.exists_by_field("name", "不存在")
|
||||
target = level_repo.get_one(name="甲")
|
||||
assert not level_repo.exists_by_field("name", "甲", exclude_id=target.id)
|
||||
|
||||
# get_one / get_all / count / filter_by(None 值跳过 + 自动软删过滤)
|
||||
assert level_repo.get_one(name="乙").sort == 2
|
||||
assert len(level_repo.get_all(status=1)) == 2
|
||||
assert level_repo.count(status=1) == 2
|
||||
rows = level_repo.filter_by(status=1).order_by(Level.sort.asc()).all()
|
||||
assert [row.name for row in rows] == ["甲", "乙"]
|
||||
|
||||
# 软删后 count 自动排除
|
||||
level_repo.batch_delete(str(target.id))
|
||||
assert level_repo.count() == 2
|
||||
@@ -0,0 +1,69 @@
|
||||
"""schema 校验回归:mobile/email 格式 + click/sort 上限(防 INT 溢出 500)。"""
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from modules.system.user.schemas import UserForm, UserInfoForm
|
||||
from modules.article.schemas import ArticleForm
|
||||
from modules.notice.schemas import NoticeForm
|
||||
from modules.param.schemas import ParamForm
|
||||
from modules.file_template.schemas import FileTemplateForm
|
||||
|
||||
|
||||
def _base_user():
|
||||
return dict(
|
||||
realname='张三', nickname='张三', gender=1, avatar='a.png',
|
||||
mobile='13800000000', email='a@b.com', birthday=date(2000, 1, 1),
|
||||
deptId=1, levelId=1, positionId=1,
|
||||
city=['110000', '110100', '110101'], address='北京市',
|
||||
username='zhangsan', password='123456', status=1, sort=1, roles=[1, 2],
|
||||
)
|
||||
|
||||
|
||||
def test_user_mobile_accepts_valid_and_rejects_bad():
|
||||
UserForm(**{**_base_user(), 'mobile': '13800000000'})
|
||||
for bad in ('abc', '12345', '23800000000', '1380000000', '138000000001'):
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**_base_user(), 'mobile': bad})
|
||||
|
||||
|
||||
def test_user_email_accepts_valid_and_rejects_bad():
|
||||
UserForm(**{**_base_user(), 'email': 'a@b.com'})
|
||||
for bad in ('not-an-email', 'a@b', '@b.com', 'a b@c.com'):
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**_base_user(), 'email': bad})
|
||||
|
||||
|
||||
def test_user_info_form_mobile_email_format():
|
||||
# 修改个人信息表单同样校验格式
|
||||
base = dict(realname='张三', nickname='zs', gender=1, address='x')
|
||||
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'a@b.com'})
|
||||
with pytest.raises(ValidationError):
|
||||
UserInfoForm(**{**base, 'mobile': 'abc', 'email': 'a@b.com'})
|
||||
with pytest.raises(ValidationError):
|
||||
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'bad'})
|
||||
|
||||
|
||||
def test_article_click_overflow_rejected():
|
||||
ArticleForm(title='t', click=2147483647, sort=1)
|
||||
with pytest.raises(ValidationError):
|
||||
ArticleForm(title='t', click=2147483648, sort=1)
|
||||
|
||||
|
||||
def test_notice_click_overflow_rejected():
|
||||
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483647)
|
||||
with pytest.raises(ValidationError):
|
||||
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483648)
|
||||
|
||||
|
||||
def test_param_sort_overflow_rejected():
|
||||
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=99999)
|
||||
with pytest.raises(ValidationError):
|
||||
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=100000)
|
||||
|
||||
|
||||
def test_file_template_sort_overflow_rejected():
|
||||
FileTemplateForm(sort=99999)
|
||||
with pytest.raises(ValidationError):
|
||||
FileTemplateForm(sort=100000)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""超管账号与内置角色写保护:user id==1 与内置角色(种子数据)禁止被管理写操作影响。"""
|
||||
import asyncio
|
||||
import json
|
||||
import types
|
||||
|
||||
from core import R
|
||||
from core.base_service import BaseService
|
||||
|
||||
import modules.system.user.service as user_service
|
||||
import modules.system.role.service as role_service
|
||||
from modules.system.role.repository import role_repo
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
# ============ 超管账号(user id==1)写保护 ============
|
||||
|
||||
def test_delete_user_blocks_superadmin():
|
||||
result = asyncio.run(user_service.delete_user('1'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_user_allows_normal_user(monkeypatch):
|
||||
# 屏蔽会话与批量删除,仅验证非超管不会被误拦
|
||||
class _FakeDelete:
|
||||
def delete(self, *a, **k):
|
||||
return 1
|
||||
|
||||
class _FakeQuery:
|
||||
def filter(self, *a, **k):
|
||||
return _FakeDelete()
|
||||
|
||||
monkeypatch.setattr(user_service, 'get_db_session',
|
||||
lambda: types.SimpleNamespace(query=lambda m: _FakeQuery()))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_batch_delete(repo, ids):
|
||||
captured['ids'] = ids
|
||||
return R.ok(msg="本次共删除{0}条数据".format(1))
|
||||
|
||||
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
|
||||
|
||||
async def fake_invalidate(uid):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(user_service, 'invalidate_user_perms', fake_invalidate)
|
||||
|
||||
result = asyncio.run(user_service.delete_user('2'))
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '2'
|
||||
|
||||
|
||||
def test_update_user_blocks_superadmin(fake_request):
|
||||
result = asyncio.run(user_service.update_user(fake_request, types.SimpleNamespace(id=1)))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_update_user_status_blocks_superadmin(fake_request):
|
||||
result = asyncio.run(
|
||||
user_service.update_user_status(fake_request, types.SimpleNamespace(id=1, status=2)))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_reset_user_pwd_blocks_superadmin(fake_request):
|
||||
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=1))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_reset_user_pwd_allows_normal_user(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(user_service.user_repo, 'get_by_id', lambda uid: object())
|
||||
monkeypatch.setattr(user_service.user_repo, 'update', lambda uid, fields: True)
|
||||
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=2))
|
||||
assert _body(result)['ok'] is True
|
||||
|
||||
|
||||
# ============ 内置角色写保护 ============
|
||||
|
||||
def test_delete_role_blocks_builtin():
|
||||
result = asyncio.run(role_service.delete_role('1'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '内置' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_role_blocks_mixed_builtin():
|
||||
# 批量删除中混入内置角色 → 整体拒绝
|
||||
result = asyncio.run(role_service.delete_role('2,5'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '内置' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_role_allows_normal_role(monkeypatch, sqlite_db):
|
||||
class _FakeRecord:
|
||||
is_delete = 0
|
||||
|
||||
class _FakeQuery:
|
||||
def all(self):
|
||||
return [_FakeRecord()]
|
||||
|
||||
monkeypatch.setattr(role_repo, 'filter', lambda *a, **k: _FakeQuery())
|
||||
|
||||
async def fake_invalidate(rid):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(role_service, 'invalidate_role_perms', fake_invalidate)
|
||||
|
||||
# sqlite_db 提供请求级 DB 会话(delete_role 会清理 user_role/role_menu 关联)
|
||||
result = asyncio.run(role_service.delete_role('5'))
|
||||
assert _body(result)['ok'] is True
|
||||
@@ -0,0 +1,84 @@
|
||||
"""部分更新防 NULL 覆盖:编辑时未传的可选字段不得把数据库旧值清空为 NULL。"""
|
||||
import json
|
||||
|
||||
from modules.file_template.schemas import FileTemplateForm
|
||||
from modules.file_template.service import file_template_service
|
||||
from modules.city.schemas import CityForm
|
||||
from modules.city.service import update_city
|
||||
from modules.city.repository import city_repo
|
||||
from modules.system.dept.schemas import DeptForm
|
||||
from modules.system.dept.service import update_dept
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.article.schemas import ArticleForm
|
||||
from modules.article.service import update_article
|
||||
from modules.article.repository import article_repo
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
# ============ base 层 _build_update_fields ============
|
||||
|
||||
def test_build_update_fields_excludes_none(fake_request):
|
||||
data = FileTemplateForm(id=1, name='模板', sort=5) # code/file_name/file_path/note 未传 → None
|
||||
fields = file_template_service._build_update_fields(fake_request, data)
|
||||
assert 'id' not in fields
|
||||
assert 'name' in fields
|
||||
assert 'sort' in fields
|
||||
assert 'code' not in fields
|
||||
assert 'filePath' not in fields
|
||||
assert 'update_user' in fields
|
||||
assert 'update_time' in fields
|
||||
|
||||
|
||||
# ============ city:编辑顶级时 parent_id 不被清空 ============
|
||||
|
||||
def test_update_city_preserves_parent_id_when_omitted(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(city_repo, 'get_by_id', lambda uid: object())
|
||||
captured = {}
|
||||
monkeypatch.setattr(city_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = CityForm(
|
||||
id=1, name='省', shortName='s', pinyin='p', cityCode='010',
|
||||
areaCode='11', zipCode='100', level=1, lng='0', lat='0',
|
||||
) # parentId 未传 → None
|
||||
result = update_city(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'parentId' not in captured
|
||||
|
||||
|
||||
# ============ dept:编辑顶级时 parent_id 不被清空 ============
|
||||
|
||||
def test_update_dept_preserves_parent_id_when_omitted(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(dept_repo, 'exists_by_field', lambda *a, **k: False)
|
||||
monkeypatch.setattr(dept_repo, 'get_by_id', lambda uid: object())
|
||||
captured = {}
|
||||
monkeypatch.setattr(dept_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = DeptForm(id=1, name='部门', code='D1', type=1, sort=1) # parent_id/note 未传 → None
|
||||
result = update_dept(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'parentId' not in captured
|
||||
assert 'note' not in captured
|
||||
|
||||
|
||||
# ============ article:未传的 category_id/intro/author 不被清空 ============
|
||||
|
||||
def test_update_article_preserves_optional_fields_when_omitted(monkeypatch, fake_request):
|
||||
class _OldArticle:
|
||||
cover = None
|
||||
images = None
|
||||
content = None
|
||||
|
||||
monkeypatch.setattr(article_repo, 'get_by_id', lambda uid: _OldArticle())
|
||||
captured = {}
|
||||
monkeypatch.setattr(article_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = ArticleForm(id=1, title='标题', status=0) # category_id/intro/author 未传 → None
|
||||
result = update_article(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'title' in captured
|
||||
assert 'categoryId' not in captured
|
||||
assert 'intro' not in captured
|
||||
assert 'author' not in captured
|
||||
@@ -0,0 +1,87 @@
|
||||
"""上传安全:SVG/WebP 整体禁止上传(存储型 XSS 面 / 无可靠魔数)。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from modules.upload import service as upload_service
|
||||
from modules.upload.service import _save_upload, upload_file
|
||||
|
||||
|
||||
class _FakeUpload:
|
||||
"""模拟异步分块读取的 UploadFile。"""
|
||||
|
||||
def __init__(self, filename, data=b''):
|
||||
self.filename = filename
|
||||
self._data = data
|
||||
|
||||
async def read(self, size=65536):
|
||||
data = self._data
|
||||
self._data = b''
|
||||
return data
|
||||
|
||||
|
||||
def test_upload_file_rejects_svg(monkeypatch):
|
||||
# SVG 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg')
|
||||
result = asyncio.run(upload_file(_FakeUpload('logo.svg', b'<svg/>')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_save_upload_rejects_svg_even_if_config_allows():
|
||||
# 兜底硬拒绝:即使配置或其他入口放行 .svg,落盘前一律拒绝
|
||||
content = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
result = _save_upload(content, 'a.svg', '.svg')
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert 'SVG' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_rejects_webp(monkeypatch):
|
||||
# WebP 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.webp')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.webp', b'RIFF' + b'\x00' * 32)))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_save_upload_rejects_webp_even_if_config_allows():
|
||||
# 兜底硬拒绝:webp 魔数仅 RIFF 前缀(WAV/AVI 可冒充),落盘前一律拒绝
|
||||
result = _save_upload(b'RIFF' + b'\x00' * 32, 'a.webp', '.webp')
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert 'WebP' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_uses_builtin_set_when_unconfigured(monkeypatch):
|
||||
# 方案A:未配置时回落内置智能集合,白名单校验放行内置格式
|
||||
from core import R
|
||||
called = {}
|
||||
|
||||
def fake_save(content, original_name, ext):
|
||||
called['ext'] = ext
|
||||
return R.ok(msg="ok", data={})
|
||||
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '')
|
||||
monkeypatch.setattr(upload_service, '_save_upload', fake_save)
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is True
|
||||
assert called['ext'] == '.jpg'
|
||||
|
||||
|
||||
def test_upload_file_fail_closed_when_config_outside_builtin(monkeypatch):
|
||||
# 配置的扩展名全不在内置集合内 → 有效白名单为空 → 拒绝一切上传
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg,.webp,.exe')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert '允许上传范围' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_rejects_ext_outside_effective_set(monkeypatch):
|
||||
# 配置收缩白名单后,内置集合中但不在配置内的扩展名被拒绝
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.jpg,.png')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.txt', b'hello')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert '文件格式' in body['msg']
|
||||
Reference in New Issue
Block a user