签入版本
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""启动期安全配置校验 fail-close + 上传白名单双层机制(内置集合 ∩ 配置)。"""
|
||||
import pytest
|
||||
|
||||
import core.config as config
|
||||
from constants.constants import BUILTIN_UPLOAD_EXTS
|
||||
|
||||
|
||||
def _set(monkeypatch, **kwargs):
|
||||
for name, value in kwargs.items():
|
||||
monkeypatch.setattr(config, name, value)
|
||||
|
||||
|
||||
def test_validate_config_passes_with_valid_values():
|
||||
# 默认 .env 为有效配置,不应抛异常
|
||||
config.validate_config()
|
||||
|
||||
|
||||
# ============ 上传白名单:有效集合 = 配置 ∩ 内置集合,未配置回落内置集合 ============
|
||||
|
||||
def test_effective_upload_exts_falls_back_to_builtin_when_empty():
|
||||
assert set(config.effective_upload_exts('')) == BUILTIN_UPLOAD_EXTS
|
||||
assert set(config.effective_upload_exts(' ')) == BUILTIN_UPLOAD_EXTS
|
||||
|
||||
|
||||
def test_effective_upload_exts_intersects_with_builtin():
|
||||
# 配置含内置集合外的危险后缀 → 交集兜底,仅保留允许项
|
||||
assert config.effective_upload_exts('.jpg,.svg,.py') == ['.jpg']
|
||||
|
||||
|
||||
def test_effective_upload_exts_empty_when_all_outside_builtin():
|
||||
assert config.effective_upload_exts('.svg,.webp,.exe') == []
|
||||
|
||||
|
||||
def test_effective_upload_exts_normalizes_case_and_space():
|
||||
assert config.effective_upload_exts('.JPG, .PNG, txt') == ['.jpg', '.png', '.txt']
|
||||
|
||||
|
||||
# ============ 启动检测 validate_config ============
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_empty(monkeypatch):
|
||||
# 方案A:未配置 → 回落内置智能集合,不拒绝启动
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_blank(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS=' ')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_subset(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.jpg,.png')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_fails_when_upload_exts_all_outside_builtin(monkeypatch):
|
||||
# 已配置但交集为空(配置的全是不允许的后缀)→ 有效白名单为空,拒绝启动
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp,.py')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'UPLOAD_ALLOWED_EXTS' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_fails_when_jwt_salt_empty(monkeypatch):
|
||||
_set(monkeypatch, JWT_SALT='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'JWT_SALT' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_fails_when_jwt_salt_too_short(monkeypatch):
|
||||
_set(monkeypatch, JWT_SALT='short-secret-123') # 16 字节 < 32
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'JWT_SALT' in str(exc_info.value)
|
||||
assert '32' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_reports_both_errors(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp', JWT_SALT='weak')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
message = str(exc_info.value)
|
||||
assert 'UPLOAD_ALLOWED_EXTS' in message
|
||||
assert 'JWT_SALT' in message
|
||||
|
||||
|
||||
# ============ 凭据 fail-close:DB_PASSWORD / REDIS_PASSWORD ============
|
||||
|
||||
def test_validate_config_fails_when_db_password_empty(monkeypatch):
|
||||
_set(monkeypatch, DB_DRIVER='mysql', DB_PASSWORD='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'DB_PASSWORD' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_passes_when_sqlite_without_password(monkeypatch):
|
||||
# sqlite 无需密码,不应因 DB_PASSWORD 为空而拒绝
|
||||
_set(monkeypatch, DB_DRIVER='sqlite', DB_PASSWORD='')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_fails_when_redis_auth_without_password(monkeypatch):
|
||||
_set(monkeypatch, REDIS_AUTH=True, REDIS_PASSWORD='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'REDIS_PASSWORD' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_passes_when_redis_no_auth_without_password(monkeypatch):
|
||||
# REDIS_AUTH=False 时无需密码
|
||||
_set(monkeypatch, REDIS_AUTH=False, REDIS_PASSWORD='')
|
||||
config.validate_config()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""多数据库接入:URL 构建 / 驱动归一化 / 方言感知引擎。
|
||||
|
||||
覆盖 DB_DRIVER=mysql|postgresql|mssql|sqlite|oracle 的连接串生成、别名归一化、
|
||||
未知驱动 fail-fast,以及 sqlite 引擎构建不引入 pymysql。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
import core.config as config
|
||||
import core.config.database as database
|
||||
from core.database import build_engine
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
# ============ build_database_url 各驱动 ============
|
||||
|
||||
def test_mysql_url_shape():
|
||||
url = database.build_database_url('mysql')
|
||||
assert url.startswith('mysql+pymysql://')
|
||||
assert url.endswith('?charset=utf8mb4')
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_postgresql_url_shape():
|
||||
url = database.build_database_url('postgresql')
|
||||
assert url.startswith('postgresql+psycopg://')
|
||||
assert 'charset' not in url
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_mssql_url_shape():
|
||||
url = database.build_database_url('mssql')
|
||||
assert url.startswith('mssql+pymssql://')
|
||||
assert url.endswith('?charset=utf8')
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_oracle_url_shape():
|
||||
url = database.build_database_url('oracle')
|
||||
assert url.startswith('oracle+oracledb://')
|
||||
assert 'service_name=' in url
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/' in url
|
||||
|
||||
|
||||
def test_sqlite_url_uses_db_database_as_path(monkeypatch):
|
||||
monkeypatch.setattr(database, 'DB_DATABASE', 'data/dev.db')
|
||||
assert database.build_database_url('sqlite') == 'sqlite:///./data/dev.db'
|
||||
# DB_DATABASE 为绝对路径时直接透传
|
||||
monkeypatch.setattr(database, 'DB_DATABASE', 'C:/apps/dev.db')
|
||||
assert database.build_database_url('sqlite') == 'sqlite:///C:/apps/dev.db'
|
||||
|
||||
|
||||
def test_sqlite_url_absolute_path():
|
||||
assert database._sqlite_url('C:/apps/dev.db') == 'sqlite:///C:/apps/dev.db'
|
||||
if os.name == 'posix':
|
||||
# POSIX 绝对路径 → sqlite:////abs/x.db(Windows 下 /abs 仅相对当前盘,属相对路径)
|
||||
assert database._sqlite_url('/abs/x.db') == 'sqlite:////abs/x.db'
|
||||
|
||||
|
||||
def test_url_quote_plus_credentials(monkeypatch):
|
||||
monkeypatch.setattr(database, 'DB_USERNAME', 'user name')
|
||||
monkeypatch.setattr(database, 'DB_PASSWORD', 'p@ss/w:?')
|
||||
url = database.build_database_url('mysql')
|
||||
assert 'user+name' in url
|
||||
assert 'p%40ss%2Fw%3A%3F' in url
|
||||
|
||||
|
||||
# ============ _normalize_driver 别名 / 大小写 / 未知 ============
|
||||
|
||||
def test_normalize_driver_aliases_and_case():
|
||||
assert database._normalize_driver(' POSTGRES ') == 'postgresql'
|
||||
assert database._normalize_driver('postgres') == 'postgresql'
|
||||
assert database._normalize_driver('sqlserver') == 'mssql'
|
||||
assert database._normalize_driver('sql_server') == 'mssql'
|
||||
assert database._normalize_driver("'sqlite3'") == 'sqlite'
|
||||
assert database._normalize_driver('MySQL') == 'mysql'
|
||||
assert database._normalize_driver('oracle') == 'oracle'
|
||||
|
||||
|
||||
def test_build_database_url_unknown_driver_raises():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
database.build_database_url('db2') # 真正不支持的驱动
|
||||
msg = str(exc_info.value)
|
||||
assert '不支持' in msg
|
||||
assert 'mysql' in msg and 'sqlite' in msg
|
||||
|
||||
|
||||
# ============ 方言感知引擎 ============
|
||||
|
||||
def test_build_engine_sqlite_smoke():
|
||||
engine = build_engine(driver='sqlite', database_url='sqlite:///:memory:')
|
||||
|
||||
TestBase = declarative_base()
|
||||
|
||||
class T(TestBase):
|
||||
__tablename__ = 't_smoke'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(50))
|
||||
|
||||
TestBase.metadata.create_all(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(T.__table__.insert(), {'id': 1, 'name': 'x'})
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(T.__table__.select()).first()
|
||||
assert row.name == 'x'
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_sqlite_engine_does_not_import_pymysql():
|
||||
"""sqlite 路径构建引擎不得引入 pymysql(子进程隔离,避免会话内已有缓存)。"""
|
||||
code = (
|
||||
"import sys\n"
|
||||
"sys.path.insert(0, 'src')\n"
|
||||
"import core.database\n"
|
||||
"print('pymysql' in sys.modules)\n"
|
||||
)
|
||||
env = {**os.environ, 'DB_DRIVER': 'sqlite'}
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-c', code],
|
||||
capture_output=True, text=True, cwd=_PROJECT_ROOT, env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == 'False'
|
||||
@@ -0,0 +1,46 @@
|
||||
"""基础设施资源上限回归:日志文件轮转 + Redis 连接池上限。"""
|
||||
import logging
|
||||
import logging.handlers
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config.redis import REDIS_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_file_handler_is_rotating():
|
||||
from core.logger import setup_logging
|
||||
|
||||
setup_logging()
|
||||
root = logging.getLogger()
|
||||
file_handlers = [
|
||||
h for h in root.handlers
|
||||
if getattr(h, '_djangoadmin', False) and hasattr(h, 'baseFilename')
|
||||
]
|
||||
assert file_handlers, "应存在文件日志 handler"
|
||||
for h in file_handlers:
|
||||
# 文件日志必须使用 RotatingFileHandler(按大小轮转,防单文件无限增长)
|
||||
assert isinstance(h, logging.handlers.RotatingFileHandler)
|
||||
assert h.maxBytes > 0
|
||||
assert h.backupCount > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_redis_pool_has_connection_cap():
|
||||
from core.redis_client import create_redis_pool
|
||||
|
||||
redis = await create_redis_pool()
|
||||
try:
|
||||
# from_url 不发起连接,仅创建连接池;断言最大连接数已收敛(非默认 2**31)
|
||||
assert redis.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
|
||||
def test_create_sync_redis_has_connection_cap():
|
||||
from core.redis_client import create_sync_redis
|
||||
|
||||
client = create_sync_redis()
|
||||
try:
|
||||
assert client.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
|
||||
finally:
|
||||
client.close()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""core/jwt.py 单元测试(不依赖真实 Redis/DB)。"""
|
||||
|
||||
import logging
|
||||
import time as time_module
|
||||
|
||||
import pytest
|
||||
|
||||
from core import jwt as core_jwt
|
||||
|
||||
|
||||
def test_create_and_parse_token():
|
||||
token = core_jwt.create_token({"userId": 1, "username": "admin"})
|
||||
assert isinstance(token, str)
|
||||
result = core_jwt.parse_payload(token)
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["username"] == "admin"
|
||||
|
||||
|
||||
def test_parse_invalid_token():
|
||||
result = core_jwt.parse_payload("not.a.valid.token")
|
||||
assert result["code"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token():
|
||||
token = core_jwt.create_token({"userId": 1})
|
||||
result = await core_jwt.refresh_token(token)
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_blacklisted_token_rejected(monkeypatch):
|
||||
"""已拉黑(注销)的令牌禁止刷新,防止黑名单轮换绕过。"""
|
||||
token = core_jwt.create_token({"userId": 1})
|
||||
|
||||
async def _blacklisted(_token):
|
||||
return True
|
||||
monkeypatch.setattr(core_jwt, "is_token_blacklisted", _blacklisted)
|
||||
|
||||
result = await core_jwt.refresh_token(token)
|
||||
assert result["code"] == 1
|
||||
assert "注销" in result["msg"]
|
||||
|
||||
|
||||
def test_get_token_expiration():
|
||||
token = core_jwt.create_token({"userId": 1}, timeout=30)
|
||||
exp = core_jwt.get_token_expiration(token)
|
||||
assert exp is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blacklist_check_fail_open_alert_throttled(monkeypatch, caplog):
|
||||
"""Redis 异常时 fail-open 放行;告警节流:窗口内只记一条 ERROR,重复失败降级 DEBUG。"""
|
||||
# 重置节流状态,保证测试确定性
|
||||
monkeypatch.setattr(core_jwt, "_blacklist_check_last_alert", 0.0)
|
||||
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(time_module, "monotonic", lambda: clock["t"])
|
||||
|
||||
async def call():
|
||||
return await core_jwt.is_token_blacklisted("some.token")
|
||||
|
||||
# 首次失败 → ERROR 告警 + fail-open 放行
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
# 窗口内(+30s)重复失败 → 不再产生 ERROR
|
||||
clock["t"] += 30
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert not any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
# 超过窗口(再 +60s)→ 再次 ERROR
|
||||
clock["t"] += 60
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""shared/pagination.py 分页工具单元测试。"""
|
||||
|
||||
from constants.pagination import get_page_params
|
||||
|
||||
|
||||
def test_defaults():
|
||||
page, size = get_page_params({})
|
||||
assert page == 1
|
||||
assert size == 10
|
||||
|
||||
|
||||
def test_valid_params():
|
||||
page, size = get_page_params({"pageNo": "3", "pageSize": "20"})
|
||||
assert page == 3
|
||||
assert size == 20
|
||||
|
||||
|
||||
def test_invalid_params_fall_back():
|
||||
page, size = get_page_params({"pageNo": "abc", "pageSize": "0"})
|
||||
assert page == 1
|
||||
assert size == 10
|
||||
@@ -0,0 +1,19 @@
|
||||
"""core/password.py 单元测试。"""
|
||||
|
||||
from core import password
|
||||
|
||||
|
||||
def test_encrypt_and_verify():
|
||||
salt = password.generate_salt()
|
||||
hashed = password.encrypt_password("123456", salt)
|
||||
assert len(hashed) == 60
|
||||
assert password.verify_password("123456", salt, hashed)
|
||||
assert not password.verify_password("wrong", salt, hashed)
|
||||
|
||||
|
||||
def test_same_password_different_hash():
|
||||
"""bcrypt 内置盐随机,相同密码两次加密结果不同。"""
|
||||
salt = password.generate_salt()
|
||||
h1 = password.encrypt_password("pw", salt)
|
||||
h2 = password.encrypt_password("pw", salt)
|
||||
assert h1 != h2
|
||||
@@ -0,0 +1,27 @@
|
||||
"""core/redis_client.py incr_with_expire 原子自增工具测试。"""
|
||||
import asyncio
|
||||
|
||||
from core.redis_client import incr_with_expire, _INCR_EXPIRE_LUA
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def eval(self, script, numkeys, *keys_and_args):
|
||||
self.calls.append((script, numkeys, keys_and_args))
|
||||
return 1
|
||||
|
||||
|
||||
def test_incr_with_expire_evals_atomic_script():
|
||||
redis = _FakeRedis()
|
||||
assert asyncio.run(incr_with_expire(redis, 'rl:1.2.3.4', 60)) == 1
|
||||
assert len(redis.calls) == 1
|
||||
script, numkeys, args = redis.calls[0]
|
||||
assert numkeys == 1
|
||||
assert args == ('rl:1.2.3.4', 60)
|
||||
# 脚本内必须同时包含 incr 与 expire,且 expire 仅在首次创建时执行
|
||||
assert 'incr' in script and 'expire' in script
|
||||
assert 'count == 1' in script
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""utils/request.py 工具函数单元测试。"""
|
||||
|
||||
from utils.request import parse_id_list, like_escape, like_contains
|
||||
|
||||
|
||||
def test_parse_id_list():
|
||||
assert parse_id_list("1,2,3") == [1, 2, 3]
|
||||
assert parse_id_list("1,a,3") == [1, 3]
|
||||
assert parse_id_list("") == []
|
||||
assert parse_id_list(None) == []
|
||||
|
||||
|
||||
def test_like_escape():
|
||||
assert like_escape("50%") == "50\\%"
|
||||
assert like_escape("a_b") == "a\\_b"
|
||||
|
||||
|
||||
def test_like_contains():
|
||||
assert like_contains("张") == "%张%"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""core/response.py 响应格式单元测试。"""
|
||||
|
||||
import json
|
||||
|
||||
from core import response as R
|
||||
|
||||
|
||||
def test_ok_shape():
|
||||
resp = R.ok(data={"a": 1})
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 0
|
||||
assert body["ok"] is True
|
||||
assert body["data"] == {"a": 1}
|
||||
assert body["msg"] == "操作成功"
|
||||
|
||||
|
||||
def test_ok_extra_kwargs():
|
||||
resp = R.ok(data=[], count=10)
|
||||
body = json.loads(resp.body)
|
||||
assert body["count"] == 10
|
||||
|
||||
|
||||
def test_failed_shape():
|
||||
resp = R.failed(msg="错误信息")
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 1
|
||||
assert body["ok"] is False
|
||||
assert body["msg"] == "错误信息"
|
||||
|
||||
|
||||
def test_page_shape():
|
||||
resp = R.page(data=[{"id": 1}, {"id": 2}], total=10, current=1, size=5)
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 0
|
||||
assert body["ok"] is True
|
||||
assert body["msg"] == "操作成功"
|
||||
d = body["data"]
|
||||
assert d["records"] == [{"id": 1}, {"id": 2}]
|
||||
assert d["total"] == 10
|
||||
assert d["size"] == 5
|
||||
assert d["current"] == 1
|
||||
assert d["pages"] == 2
|
||||
|
||||
|
||||
def test_page_empty():
|
||||
resp = R.page(data=[], total=0, current=1, size=10)
|
||||
body = json.loads(resp.body)
|
||||
d = body["data"]
|
||||
assert d["records"] == []
|
||||
assert d["total"] == 0
|
||||
assert d["pages"] == 0
|
||||
Reference in New Issue
Block a user