签入版本
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""core 层单元测试。"""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
业务异常单元测试
|
||||
=============================================================
|
||||
|
||||
验证 BizError 及子类的 msg/code 约定:
|
||||
业务层 raise BizError → 全局异常处理器转 R.failed。
|
||||
"""
|
||||
|
||||
from src.core.exceptions import BizError, NotFoundError, PermissionDeniedError
|
||||
|
||||
|
||||
class TestBizError:
|
||||
def test_default_values(self):
|
||||
"""默认 msg='操作失败',code=1"""
|
||||
e = BizError()
|
||||
assert e.msg == "操作失败"
|
||||
assert e.code == 1
|
||||
|
||||
def test_custom_msg_and_code(self):
|
||||
e = BizError("自定义错误", code=403)
|
||||
assert e.msg == "自定义错误"
|
||||
assert e.code == 403
|
||||
|
||||
def test_is_exception(self):
|
||||
assert issubclass(BizError, Exception)
|
||||
|
||||
|
||||
class TestSubclasses:
|
||||
def test_not_found(self):
|
||||
e = NotFoundError()
|
||||
assert e.msg == "资源不存在"
|
||||
assert e.code == 1
|
||||
|
||||
def test_permission_denied(self):
|
||||
e = PermissionDeniedError()
|
||||
assert e.msg == "权限不足"
|
||||
assert e.code == 403
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
JWT 滑动会话单元测试
|
||||
=============================================================
|
||||
|
||||
验证:
|
||||
- create_session:登录后写入 Redis 会话键,TTL = 空闲超时
|
||||
- touch_session:活跃请求重置会话 TTL(返回 True)
|
||||
- touch_session:空闲超时后会话键缺失 → 返回 False(需重新登录)
|
||||
- touch_session:Redis 异常时 fail-closed(返回 False)
|
||||
- 会话键基于 Token SHA256 指纹,不落完整 Token
|
||||
|
||||
测试不依赖真实 Redis,通过 monkeypatch 注入内存假 Redis。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import jwt as jwt_module
|
||||
from src.core import redis_client as redis_client_module
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""内存版 Redis 桩:仅支持滑动会话用到的 set/expire"""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
self.ttls = {}
|
||||
|
||||
def set(self, key, value, expire=None):
|
||||
self.store[key] = value
|
||||
if expire is not None:
|
||||
self.ttls[key] = expire
|
||||
return True
|
||||
|
||||
def expire(self, key, seconds):
|
||||
if key not in self.store:
|
||||
return False
|
||||
self.ttls[key] = seconds
|
||||
return True
|
||||
|
||||
|
||||
class FailingRedis:
|
||||
"""模拟 Redis 故障:expire 抛异常"""
|
||||
|
||||
def set(self, key, value, expire=None):
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
def expire(self, key, seconds):
|
||||
raise ConnectionError("redis down")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis(monkeypatch):
|
||||
fake = FakeRedis()
|
||||
monkeypatch.setattr(redis_client_module, "redis", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_session_key_is_fingerprint_based():
|
||||
"""会话键基于 Token 指纹,不含完整 Token"""
|
||||
key = jwt_module._session_key("a" * 200)
|
||||
assert key.startswith("jwt:session:")
|
||||
assert len(key) > len("jwt:session:") # 含 SHA256 摘要
|
||||
assert "a" * 200 not in key # 未落完整 Token
|
||||
|
||||
|
||||
def test_create_session_sets_ttl(fake_redis):
|
||||
token = "some-jwt-token"
|
||||
assert jwt_module.create_session(token) is True
|
||||
key = jwt_module._session_key(token)
|
||||
assert fake_redis.store[key] == "1"
|
||||
assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60
|
||||
|
||||
|
||||
def test_create_session_custom_idle(fake_redis):
|
||||
token = "custom-idle-token"
|
||||
assert jwt_module.create_session(token, idle_minutes=5) is True
|
||||
key = jwt_module._session_key(token)
|
||||
assert fake_redis.ttls[key] == 5 * 60
|
||||
|
||||
|
||||
def test_touch_session_resets_ttl(fake_redis):
|
||||
token = "touch-token"
|
||||
assert jwt_module.create_session(token, idle_minutes=5) is True
|
||||
|
||||
# 活跃请求续期:TTL 重置为完整空闲超时
|
||||
assert jwt_module.touch_session(token) is True
|
||||
key = jwt_module._session_key(token)
|
||||
assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60
|
||||
|
||||
|
||||
def test_touch_session_missing_key_returns_false(fake_redis):
|
||||
"""空闲超时后会话键已消失 → touch_session 返回 False(需重新登录)"""
|
||||
assert jwt_module.touch_session("never-logged-in-token") is False
|
||||
assert fake_redis.store == {}
|
||||
|
||||
|
||||
def test_touch_session_fail_closed_on_redis_error(monkeypatch):
|
||||
"""Redis 故障时 touch_session 返回 False(fail-closed)"""
|
||||
monkeypatch.setattr(redis_client_module, "redis", FailingRedis())
|
||||
assert jwt_module.touch_session("any-token") is False
|
||||
|
||||
|
||||
def test_create_session_fail_closed_on_redis_error(monkeypatch):
|
||||
"""Redis 故障时 create_session 返回 False"""
|
||||
monkeypatch.setattr(redis_client_module, "redis", FailingRedis())
|
||||
assert jwt_module.create_session("any-token") is False
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
中间件单元测试
|
||||
=============================================================
|
||||
|
||||
验证:
|
||||
- 敏感字段递归脱敏(密码/salt/令牌等不会写入操作日志)
|
||||
- 操作日志标题:@operation_log 声明的元数据被 _build_title 读取;未标注走兜底
|
||||
"""
|
||||
|
||||
from src.middleware import _is_excluded_path, _redact_sensitive, _build_title, operation_log
|
||||
|
||||
|
||||
class TestRedactSensitive:
|
||||
"""递归脱敏"""
|
||||
|
||||
def test_redacts_sensitive_keys(self):
|
||||
data = {
|
||||
"username": "admin",
|
||||
"password": "plaintext123",
|
||||
"code": "business-code", # 业务字段(role.code 等)不得误打码
|
||||
"key": "business-key",
|
||||
"nested": {"salt": "s1", "ok": 1},
|
||||
"list": [{"token": "jwt"}, "plain"],
|
||||
}
|
||||
result = _redact_sensitive(data)
|
||||
|
||||
assert result["password"] == "******"
|
||||
assert result["code"] == "business-code" # 不再误脱敏
|
||||
assert result["key"] == "business-key"
|
||||
assert result["nested"]["salt"] == "******"
|
||||
assert result["nested"]["ok"] == 1
|
||||
assert result["list"][0]["token"] == "******"
|
||||
assert result["list"][1] == "plain"
|
||||
assert result["username"] == "admin"
|
||||
|
||||
def test_non_dict_passthrough(self):
|
||||
assert _redact_sensitive("plain") == "plain"
|
||||
assert _redact_sensitive(123) == 123
|
||||
assert _redact_sensitive(None) is None
|
||||
|
||||
|
||||
class TestBuildTitle:
|
||||
"""@operation_log 元数据解析 + 兜底"""
|
||||
|
||||
def test_uses_view_metadata(self, flask_app):
|
||||
@flask_app.route('/test/add', methods=['POST'])
|
||||
@operation_log("测试模块", "新增")
|
||||
def add():
|
||||
return 'ok'
|
||||
|
||||
with flask_app.app_context():
|
||||
# 裸 Flask app 的 endpoint 即视图函数名
|
||||
title = _build_title('/test/add', 'add')
|
||||
assert title == '测试模块-新增'
|
||||
|
||||
def test_action_only_uses_module(self, flask_app):
|
||||
@flask_app.route('/test/list', methods=['GET'])
|
||||
@operation_log("测试模块")
|
||||
def lst():
|
||||
return 'ok'
|
||||
|
||||
with flask_app.app_context():
|
||||
title = _build_title('/test/list', 'lst')
|
||||
assert title == '测试模块'
|
||||
|
||||
def test_unannotated_fallback(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
title = _build_title('/api/v1/user/add', 'unannotated.endpoint')
|
||||
assert title == 'user-add'
|
||||
|
||||
|
||||
class TestExcludedPath:
|
||||
"""敏感端点尾段匹配:兼容任意版本前缀"""
|
||||
|
||||
def test_v1_paths_excluded(self):
|
||||
assert _is_excluded_path("/api/v1/login")
|
||||
assert _is_excluded_path("/api/v1/captcha")
|
||||
assert _is_excluded_path("/api/v1/logout")
|
||||
assert _is_excluded_path("/api/v1/job/sendSms")
|
||||
|
||||
def test_v2_prefix_also_excluded(self):
|
||||
# 尾段匹配 → 后续 v2/v3 无需追加前缀配置
|
||||
assert _is_excluded_path("/api/v2/login")
|
||||
assert _is_excluded_path("/api/v2/job/sendSms")
|
||||
|
||||
def test_normal_path_not_excluded(self):
|
||||
assert not _is_excluded_path("/api/v1/user/add")
|
||||
assert not _is_excluded_path("/api/v1/job/page")
|
||||
assert not _is_excluded_path("/api/v1/job/runOnce/1")
|
||||
|
||||
def test_root_mounted_paths_excluded(self):
|
||||
assert _is_excluded_path("/login")
|
||||
assert _is_excluded_path("/captcha")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
R 统一响应格式单元测试
|
||||
|
||||
验证 `R.ok()` / `R.failed()` 返回的 JSON 响应结构符合前后端约定:
|
||||
{code, data, msg, ok}(code=0 成功,非 0 失败)。
|
||||
|
||||
说明:`R.ok()` 内部使用 `jsonify`,须在 Flask 应用上下文内调用
|
||||
(fixture:flask_app,见 tests/conftest.py)。
|
||||
"""
|
||||
|
||||
from src.core import response as R
|
||||
|
||||
|
||||
class TestOk:
|
||||
"""成功响应"""
|
||||
|
||||
def test_ok_default_format(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
resp = R.ok()
|
||||
body = resp.get_json()
|
||||
|
||||
assert body["code"] == 0
|
||||
assert body["ok"] is True
|
||||
assert body["data"] is None
|
||||
assert body["msg"]
|
||||
|
||||
def test_ok_with_data(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
resp = R.ok(data={"id": 1, "name": "张三"})
|
||||
body = resp.get_json()
|
||||
|
||||
assert body["code"] == 0
|
||||
assert body["data"] == {"id": 1, "name": "张三"}
|
||||
|
||||
def test_ok_with_kwargs(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
resp = R.ok(data=[], total=100)
|
||||
body = resp.get_json()
|
||||
|
||||
assert body["code"] == 0
|
||||
assert body["total"] == 100
|
||||
|
||||
|
||||
class TestFailed:
|
||||
"""失败响应"""
|
||||
|
||||
def test_failed_default_format(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
resp = R.failed()
|
||||
body = resp.get_json()
|
||||
|
||||
assert body["code"] != 0
|
||||
assert body["ok"] is False
|
||||
assert body["data"] is None
|
||||
|
||||
def test_failed_custom_code(self, flask_app):
|
||||
with flask_app.app_context():
|
||||
resp = R.failed(msg="未授权访问", code=401)
|
||||
body = resp.get_json()
|
||||
|
||||
assert body["code"] == 401
|
||||
assert body["msg"] == "未授权访问"
|
||||
Reference in New Issue
Block a user