签入版本

This commit is contained in:
2026-09-12 18:24:41 +08:00
commit fb2b7d9538
852 changed files with 115466 additions and 0 deletions
View File
+13
View File
@@ -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
+38
View File
@@ -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
+198
View File
@@ -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
+42
View File
@@ -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
+101
View File
@@ -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']
+102
View File
@@ -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 == {}
+301
View File
@@ -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)
+45
View File
@@ -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 == '张三'
+163
View File
@@ -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'] == '用户管理'
+44
View File
@@ -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"
+60
View File
@@ -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
+87
View File
@@ -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']