# -*- coding: utf-8 -*- """BUG-5 ~ BUG-9 及 H-1 ~ H-4 修复验证测试。""" import asyncio import json import sys import pytest sys.path.insert(0, 'src') # ============================================================ # BUG-5: 权限装饰器走缓存(异步 + 同步端点) # ============================================================ def _make_perm_app(monkeypatch, user_id, perm_calls, denied=False): from fastapi import FastAPI from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from core.access_decorators import permission_required async def fake_get_permissions_list(uid): perm_calls.append(uid) return [] if denied else ['sys:x:list'] import modules.system.menu.service as menu_service monkeypatch.setattr(menu_service, 'get_permissions_list', fake_get_permissions_list) sync_db_calls = [] monkeypatch.setattr( menu_service, 'get_permissions_list_sync', lambda uid: sync_db_calls.append(uid) or ([] if denied else ['sys:x:list'])) app = FastAPI() @app.middleware("http") async def inject_user(request: Request, call_next): request.state.user_id = user_id return await call_next(request) @app.get("/sync") @permission_required("sys:x:list") def sync_ep(request: Request): return {"code": 0, "ok": True} @app.get("/async") @permission_required("sys:x:list") async def async_ep(request: Request): return {"code": 0, "ok": True} return app, sync_db_calls def test_perm_decorator_sync_endpoint_uses_sync_check(monkeypatch): from fastapi.testclient import TestClient calls = [] app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls) with TestClient(app) as client: resp = client.get("/sync") assert resp.json()["ok"] is True # 同步端点直接走同步 DB 校验(不再每请求新建事件循环 / 依赖 Redis 缓存) assert calls == [] assert sync_db_calls == [2] def test_perm_decorator_async_endpoint_uses_cache(monkeypatch): from fastapi.testclient import TestClient calls = [] app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls) with TestClient(app) as client: resp = client.get("/async") assert resp.json()["ok"] is True assert calls == [2] assert sync_db_calls == [] def test_perm_decorator_denied(monkeypatch): from fastapi.testclient import TestClient calls = [] app, sync_db_calls = _make_perm_app( monkeypatch, user_id=2, perm_calls=calls, denied=True) with TestClient(app) as client: resp = client.get("/async") assert resp.json()["code"] == 1 assert resp.json()["ok"] is False def test_perm_decorator_admin_bypass(monkeypatch): from fastapi.testclient import TestClient calls = [] app, sync_db_calls = _make_perm_app(monkeypatch, user_id=1, perm_calls=calls) with TestClient(app) as client: resp = client.get("/async") assert resp.json()["ok"] is True assert calls == [] # 管理员不查权限 # ============================================================ # BUG-6: CORS 为最外层 # ============================================================ def test_cors_is_outermost(): from core.app import create_app from starlette.middleware.cors import CORSMiddleware app = create_app() classes = [m.cls for m in app.user_middleware] assert classes[0] is CORSMiddleware # ============================================================ # BUG-7: 时间字段序列化统一(naive/aware UTC 均转本地) # ============================================================ def test_to_dict_naive_utc_matches_aware_utc(): from datetime import datetime, timezone from modules.system.menu.models import Menu naive_obj = Menu() naive_obj.create_time = datetime(2026, 1, 1, 8, 0, 0) # naive,表示 UTC 08:00 aware_obj = Menu() aware_obj.create_time = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) d1 = naive_obj.to_dict() d2 = aware_obj.to_dict() # 两者应一致(本地时间),证明读回 naive 不再显示 UTC 原始值 assert d1['create_time'] == d2['create_time'] expected = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) \ .astimezone().strftime('%Y-%m-%d %H:%M:%S') assert d1['create_time'] == expected # ============================================================ # BUG-8: 导出 URL 反斜杠转 / 分隔 # ============================================================ def test_export_level_url_uses_forward_slashes(monkeypatch, tmp_path): from fastapi import FastAPI from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.testclient import TestClient import modules.system.level.service as level_svc import time up = tmp_path / 'uploads' temp = up / 'temp' monkeypatch.setattr(level_svc, 'FASTAPI_UPLOAD_DIR', str(up)) monkeypatch.setattr(level_svc, 'FASTAPI_TEMP_PATH', str(temp)) monkeypatch.setattr(level_svc, 'FASTAPI_FILE_URL', 'http://img.test') monkeypatch.setattr(level_svc, 'get_db_session', lambda: None) class FakeQuery: def filter(self, *a, **k): return self def order_by(self, *a, **k): return self def limit(self, *a, **k): return self def yield_per(self, *a, **k): return iter(()) # 无数据,跳过导出与 expunge class FakeRepo: def filter_by(self): return FakeQuery() monkeypatch.setattr(level_svc.level_service, 'repo', FakeRepo()) app = FastAPI() @app.middleware("http") async def inject(request: Request, call_next): request.state.user_id = 1 return await call_next(request) @app.get('/export') def export(request: Request): return level_svc.level_service.export_level(request) with TestClient(app) as client: resp = client.get('/export') url = resp.json()['data'] assert url.startswith('http://img.test/') assert '\\' not in url assert '/temp/' in url assert url.endswith('.xlsx') # ============================================================ # BUG-9: UserForm.city/roles 由 Any 收紧为带类型校验 # ============================================================ def _valid_user_form(): from datetime import date 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_form_city_rejects_string_and_oversize(): from pydantic import ValidationError from modules.system.user.schemas import UserForm base = _valid_user_form() # 合法数据应通过 UserForm(**base) # city 传字符串应拒绝 with pytest.raises(ValidationError): UserForm(**{**base, 'city': '北京市'}) # city 元素非字符串(Pydantic v2 不隐式转 str)应拒绝 with pytest.raises(ValidationError): UserForm(**{**base, 'city': [1, 2, 3]}) # city 超过 3 个元素应拒绝 with pytest.raises(ValidationError): UserForm(**{**base, 'city': ['1', '2', '3', '4']}) def test_user_form_roles_rejects_string(): from pydantic import ValidationError from modules.system.user.schemas import UserForm base = _valid_user_form() # roles 传字符串应拒绝 with pytest.raises(ValidationError): UserForm(**{**base, 'roles': '1,2'}) # roles 合法整型列表应通过 form = UserForm(**{**base, 'roles': [1, 2]}) assert form.roles == [1, 2] # ============================================================ # H-1: add_user 排除 id(防批量赋值伪造) # ============================================================ def _make_request(user_id=1): import types req = types.SimpleNamespace() req.state = types.SimpleNamespace(user_id=user_id) return req def test_add_user_rejects_forged_id(sqlite_db, monkeypatch): import asyncio from modules.system.user import service as user_svc from modules.system.user.schemas import UserForm from modules.system.user.repository import user_repo # 跳过头像文件落盘,直接返回原值 monkeypatch.setattr(user_svc, 'save_file', lambda url, _dir: url) # 客户端伪造 id=999,应被排除,不写入数据库 form = UserForm(**{**_valid_user_form(), 'id': 999}) asyncio.run(user_svc.add_user(_make_request(), form)) user = user_repo.get_one(username='zhangsan') assert user is not None assert user.id != 999 assert user_repo.get_by_id(999) is None # ============================================================ # H-2: login_required 直接查库校验用户状态 # ============================================================ def _make_login_app(): from fastapi import Depends, FastAPI from starlette.requests import Request from starlette.responses import JSONResponse import api.deps as deps from core.exceptions import AuthorizationException app = FastAPI() @app.exception_handler(AuthorizationException) async def _auth_exc(request: Request, exc: AuthorizationException): return JSONResponse( status_code=200, content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False}, ) @app.get('/protected') async def protected(request: Request, _=Depends(deps.login_required)): return {"code": 0, "ok": True, "user_id": request.state.user_id} return app def test_login_required_rejects_disabled_and_deleted_user(monkeypatch): import types from fastapi.testclient import TestClient import api.deps as deps import modules.system.user.repository as user_repo_mod async def fake_blacklist(_token): return False monkeypatch.setattr(deps, 'is_token_blacklisted', fake_blacklist) def _payload(user_id): return {'code': 0, 'data': {'userId': user_id, 'username': 'u', 'realname': 'r'}} class FakeRepo: def __init__(self, users): self.users = users def get_by_id(self, uid): return self.users.get(uid) # 正常用户(status=1)放行 monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(2)) monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({2: types.SimpleNamespace(status=1)})) with TestClient(_make_login_app()) as client: resp = client.get('/protected', headers={'Authorization': 'Bearer x'}) assert resp.json()['ok'] is True assert resp.json()['user_id'] == 2 # 禁用用户(status=2)拒绝 monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(3)) monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({3: types.SimpleNamespace(status=2)})) with TestClient(_make_login_app()) as client: resp = client.get('/protected', headers={'Authorization': 'Bearer x'}) assert resp.json()['ok'] is False assert resp.json()['code'] == 401 # 已删除/不存在用户(查无此人)拒绝 monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(4)) monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({})) with TestClient(_make_login_app()) as client: resp = client.get('/protected', headers={'Authorization': 'Bearer x'}) assert resp.json()['ok'] is False assert resp.json()['code'] == 401 # ============================================================ # H-2b: login_required 用户有效状态缓存(命中免查库,失效即时踢出) # ============================================================ def _login_request(): from starlette.requests import Request scope = { 'type': 'http', 'method': 'GET', 'path': '/protected', 'headers': [(b'authorization', b'Bearer x')], 'query_string': b'', 'route': None, } return Request(scope) @pytest.mark.asyncio async def test_login_required_cache_hit_skips_db(monkeypatch): import fakeredis.aioredis import api.deps as deps import modules.system.user.repository as user_repo_mod from utils import perm_cache async def _blacklist(_t): return False monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist) monkeypatch.setattr(deps, 'parse_payload', lambda _t: {'code': 0, 'data': {'userId': 2, 'username': 'u', 'realname': 'r'}}) fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True) monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis) await fake_redis.set('user:active:2', '1') # 缓存命中:不应查询数据库 class _NoDb: def get_by_id(self, uid): raise AssertionError('缓存命中时不应查询数据库') monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb()) request = _login_request() await deps.login_required(request) assert request.state.user_id == 2 @pytest.mark.asyncio async def test_login_required_cache_inactive_rejects_without_db(monkeypatch): import fakeredis.aioredis import api.deps as deps import modules.system.user.repository as user_repo_mod from utils import perm_cache from core.exceptions import AuthorizationException async def _blacklist(_t): return False monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist) monkeypatch.setattr(deps, 'parse_payload', lambda _t: {'code': 0, 'data': {'userId': 3, 'username': 'u', 'realname': 'r'}}) fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True) monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis) await fake_redis.set('user:active:3', '0') # 缓存已标记禁用 class _NoDb: def get_by_id(self, uid): raise AssertionError('缓存命中(禁用)时不应查询数据库') monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb()) with pytest.raises(AuthorizationException): await deps.login_required(_login_request()) @pytest.mark.asyncio async def test_login_required_cache_miss_queries_db_and_caches(monkeypatch): import types import fakeredis.aioredis import api.deps as deps import modules.system.user.repository as user_repo_mod from utils import perm_cache async def _blacklist(_t): return False monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist) monkeypatch.setattr(deps, 'parse_payload', lambda _t: {'code': 0, 'data': {'userId': 5, 'username': 'u', 'realname': 'r'}}) fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True) monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis) class _Repo: def get_by_id(self, uid): return types.SimpleNamespace(status=1) monkeypatch.setattr(user_repo_mod, 'user_repo', _Repo()) request = _login_request() await deps.login_required(request) assert request.state.user_id == 5 # 回源 DB 后回填缓存,供后续请求命中 assert await fake_redis.get('user:active:5') == '1' # ============================================================ # H-3: 上传流式读取,超限立即中断(内存 DoS 防护) # ============================================================ class _ChunkedFile: """模拟异步分块读取的 UploadFile(每次 read 返回一块)。""" def __init__(self, chunks): self.chunks = list(chunks) async def read(self, size=65536): if not self.chunks: return b'' return self.chunks.pop(0) @pytest.mark.asyncio async def test_read_upload_limited_aborts_before_reading_all(): from modules.upload.service import _read_upload_limited, _max_upload_bytes limit = _max_upload_bytes() # 例如 10MB chunk = b'x' * 65536 # 64KB n_chunks = limit // 65536 + 5 # 总量必然超限 file = _ChunkedFile([chunk] * n_chunks) content, err = await _read_upload_limited(file, limit) assert content is None body = json.loads(err.body) assert body['code'] == 1 assert '超过限制' in body['msg'] # 未读完全部块即中断,证明不是先整文件读入内存 assert len(file.chunks) > 0 @pytest.mark.asyncio async def test_read_upload_limited_ok_under_limit(): from modules.upload.service import _read_upload_limited, _max_upload_bytes payload = b'hello' * 100 content, err = await _read_upload_limited(_ChunkedFile([payload]), _max_upload_bytes()) assert err is None assert content == payload # ============================================================ # H-4: set_permission 生成的权限节点默认启用(status=0) # ============================================================ def test_set_permission_creates_enabled_nodes(sqlite_db): from modules.system.menu.service import set_permission from modules.system.menu.repository import menu_repo set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1) nodes = menu_repo.get_all(parent_id=1, type=1) assert len(nodes) == 2 perms = {node.permission for node in nodes} assert perms == {'sys:user:page', 'sys:user:add'} for node in nodes: assert node.status == 0 # ============================================================ # H-5: set_permission 编辑菜单时复用旧按钮节点 id,role_menu 授权不失效 # ============================================================ def test_set_permission_preserves_node_ids_on_reedit(sqlite_db): from modules.system.menu.service import set_permission from modules.system.menu.repository import menu_repo from modules.system.role_menu.models import RoleMenu from modules.system.role_menu.repository import rolemenu_repo from core.database import get_db_session # 首次保存:生成 查询(1)/添加(5) 两个按钮节点 set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1) first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)} assert set(first) == {1, 5} # 模拟角色已把"添加"按钮授权给角色 1 get_db_session().add(RoleMenu(role_id=1, menu_id=first[5])) sqlite_db.commit() # 再次编辑菜单(勾选增加 修改/10)→ 已勾选旧节点必须复用原 id set_permission(0, [1, 5, 10], '用户管理', '/system/user', 1, 1) second = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)} assert set(second) == {1, 5, 10} assert second[1] == first[1] # 查询节点 id 未变 assert second[5] == first[5] # 添加节点 id 未变 → role_menu 引用仍有效 assert second[10] not in first.values() # 修改为新增节点 # role_menu 授权仍指向有效(is_delete=0)的按钮节点 refs = rolemenu_repo.filter(RoleMenu.menu_id == first[5]).all() assert len(refs) == 1 assert menu_repo.get_by_id(first[5]) is not None def test_set_permission_soft_deletes_unchecked_nodes(sqlite_db): from modules.system.menu.service import set_permission from modules.system.menu.repository import menu_repo set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1) first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)} assert set(first) == {1, 5} # 编辑时去掉 添加(5) → 旧节点软删,不再参与鉴权 set_permission(0, [1], '用户管理', '/system/user', 1, 1) remaining = menu_repo.get_all(parent_id=1, type=1) assert [node.sort for node in remaining] == [1] assert menu_repo.get_by_id(first[5]) is None # 已软删,等值查询不可见 # ============================================================ # M-1: update_user_status 变更状态后失效权限缓存 # ============================================================ def test_update_user_status_invalidates_perm_cache(sqlite_db, monkeypatch): import asyncio from modules.system.user import service as user_svc from modules.system.user.schemas import UserStatusForm from modules.system.user.repository import user_repo # 预置超管占位记录(id=1),使被测用户获得非超管 ID,避开超管写保护 user_repo.create( username='superadmin', realname='r', nickname='n', gender=1, avatar='a', mobile='1', email='a@b.com', province_code='11', city_code='1101', district_code='110101', street_code='', address='北京市', status=1, sort=1, ) user = user_repo.create( username='u1', realname='r', nickname='n', gender=1, avatar='a', mobile='1', email='a@b.com', province_code='11', city_code='1101', district_code='110101', street_code='', address='北京市', status=1, sort=1, ) invalidated = [] async def _recorder(uid): invalidated.append(uid) monkeypatch.setattr(user_svc, 'invalidate_user_perms', _recorder) resp = asyncio.run(user_svc.update_user_status( _make_request(), UserStatusForm(id=user.id, status=2))) assert json.loads(resp.body)['code'] == 0 assert invalidated == [user.id] # ============================================================ # M-2: invalidate_role_perms 清理角色下所有用户缓存(DB 查询走线程池) # ============================================================ @pytest.mark.asyncio async def test_invalidate_role_perms_clears_all_user_caches(sqlite_db, monkeypatch): import fakeredis.aioredis from modules.system.user_role.models import UserRole from core.database import get_db_session from utils import perm_cache # 构造:角色2 → 用户5/6 get_db_session().add_all([ UserRole(user_id=5, role_id=2), UserRole(user_id=6, role_id=2), ]) sqlite_db.commit() fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True) await fake_redis.set('perm:version', '0') await fake_redis.set('perm:list:5:0', '["sys:user:add"]') await fake_redis.set('perm:menu:6:0', '[]') await fake_redis.set('perm:list:99:0', '["sys:user:page"]') # 无关用户,不应被清 monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis) await perm_cache.invalidate_role_perms(2) # 该角色下所有用户的权限/菜单缓存被清(DB 查询经 asyncio.to_thread 执行) assert await fake_redis.get('perm:list:5:0') is None assert await fake_redis.get('perm:menu:6:0') is None # 无关用户缓存不受影响 assert await fake_redis.get('perm:list:99:0') == '["sys:user:page"]' # ============================================================ # M-3: 失效缓存失败时 fail-closed 降级递增全局版本号,避免旧权限残留 2 小时 # ============================================================ @pytest.mark.asyncio async def test_invalidate_user_perms_success_no_fallback(monkeypatch): import fakeredis.aioredis from utils import perm_cache fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True) await fake_redis.set('perm:version', '0') await fake_redis.set('perm:list:5:0', '["sys:user:add"]') await fake_redis.set('perm:menu:5:0', '[]') monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis) calls = [] async def _incr_recorder(): calls.append('incr') return 1 monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder) await perm_cache.invalidate_user_perms(5) # 删除成功:不触发降级版本号递增,缓存键已删除 assert calls == [] assert await fake_redis.get('perm:list:5:0') is None assert await fake_redis.get('perm:menu:5:0') is None @pytest.mark.asyncio async def test_invalidate_user_perms_failure_falls_back_to_version_bump(monkeypatch): from utils import perm_cache def _raise(*args, **kwargs): raise RuntimeError('redis down') calls = [] async def _incr_recorder(): calls.append('incr') return 1 monkeypatch.setattr(perm_cache, 'get_redis', _raise) monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder) await perm_cache.invalidate_user_perms(5) # 删除失败 → fail-closed 降级递增全局版本号,孤立旧缓存,杜绝残留 assert calls == ['incr'] # ============================================================ # M-4: save_content 白名单清洗防存储型 XSS # ============================================================ def test_save_content_sanitizes_xss(): from utils.rich_text import save_content out = save_content('

x

', '标题', 'notice') assert '