签入版本

This commit is contained in:
2026-09-12 17:39:13 +08:00
parent 25c8b039ac
commit 3233d221b0
871 changed files with 103088 additions and 10561 deletions
+113
View File
@@ -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()
+131
View File
@@ -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'
+46
View File
@@ -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()
+55 -3
View File
@@ -1,4 +1,9 @@
"""core/jwt.py 纯函数单元测试(不依赖 Redis/DB)。"""
"""core/jwt.py 单元测试(不依赖真实 Redis/DB)。"""
import logging
import time as time_module
import pytest
from core import jwt as core_jwt
@@ -16,14 +21,61 @@ def test_parse_invalid_token():
assert result["code"] == 1
def test_refresh_token():
@pytest.mark.asyncio
async def test_refresh_token():
token = core_jwt.create_token({"userId": 1})
result = core_jwt.refresh_token(token)
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)
+1 -1
View File
@@ -1,6 +1,6 @@
"""shared/pagination.py 分页工具单元测试。"""
from shared.pagination import get_page_params
from constants.pagination import get_page_params
def test_defaults():
+27
View File
@@ -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
+23
View File
@@ -26,3 +26,26 @@ def test_failed_shape():
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