test: 补齐上线关键链路自动验证
This commit is contained in:
@@ -10,15 +10,15 @@ from app.models import Base
|
|||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
engine = create_engine(
|
engine_options = {"pool_pre_ping": True, "future": True}
|
||||||
settings.database_url,
|
if not settings.database_url.startswith("sqlite"):
|
||||||
pool_pre_ping=True,
|
engine_options.update(
|
||||||
pool_size=settings.db_pool_size,
|
pool_size=settings.db_pool_size,
|
||||||
max_overflow=settings.db_max_overflow,
|
max_overflow=settings.db_max_overflow,
|
||||||
pool_timeout=settings.db_pool_timeout,
|
pool_timeout=settings.db_pool_timeout,
|
||||||
pool_recycle=settings.db_pool_recycle,
|
pool_recycle=settings.db_pool_recycle,
|
||||||
future=True,
|
)
|
||||||
)
|
engine = create_engine(settings.database_url, **engine_options)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
ai_knowledge_base_v2/apps/backend/requirements-dev.txt
Normal file
2
ai_knowledge_base_v2/apps/backend/requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
fastapi>=0.116.0
|
fastapi==0.139.0
|
||||||
uvicorn[standard]>=0.35.0
|
uvicorn[standard]==0.50.1
|
||||||
SQLAlchemy>=2.0.40
|
SQLAlchemy==2.0.51
|
||||||
alembic>=1.16.0
|
alembic==1.18.5
|
||||||
pydantic>=2.11.0
|
pydantic==2.13.4
|
||||||
pydantic-settings>=2.10.0
|
pydantic-settings==2.14.2
|
||||||
python-dotenv>=1.1.0
|
python-dotenv==1.2.2
|
||||||
PyJWT>=2.10.0
|
PyJWT==2.13.0
|
||||||
PyMySQL>=1.1.1
|
PyMySQL==1.2.0
|
||||||
cryptography>=45.0.0
|
cryptography==46.0.5
|
||||||
httpx>=0.28.0
|
httpx==0.28.1
|
||||||
redis>=5.0.0
|
redis==7.4.1
|
||||||
openpyxl>=3.1.5
|
openpyxl==3.1.5
|
||||||
python-multipart>=0.0.20
|
python-multipart==0.0.32
|
||||||
alibabacloud_dysmsapi20170525>=4.1.2
|
alibabacloud-dysmsapi20170525==4.6.0
|
||||||
Pillow>=11.0.0
|
Pillow==12.3.0
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api import chat
|
||||||
|
from app.models import Base
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.chat_queue_runtime import ChatQueueLease
|
||||||
|
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
|
||||||
|
from app.services.chat_service import ChatService
|
||||||
|
from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService
|
||||||
|
from app.services.security_state_service import SecurityStateService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_security_state(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.services.security_state_service.get_sync_redis_client", lambda: None)
|
||||||
|
SecurityStateService._counters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_is_encrypted_and_masked():
|
||||||
|
encrypted = SecretService.encrypt("production-secret")
|
||||||
|
assert encrypted.startswith(ENCRYPTED_PREFIX)
|
||||||
|
assert "production-secret" not in encrypted
|
||||||
|
assert SecretService.decrypt(encrypted) == "production-secret"
|
||||||
|
assert SecretService.masked(encrypted) == MASKED_SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_blocks_after_limit():
|
||||||
|
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
|
||||||
|
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
|
||||||
|
assert exc.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_token_is_shared_security_state():
|
||||||
|
SecurityStateService.revoke_token("token-id", None)
|
||||||
|
assert SecurityStateService.is_token_revoked("token-id") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_quota_resets_for_new_day():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
user = User(
|
||||||
|
id=1,
|
||||||
|
phone="13800000001",
|
||||||
|
name="测试用户",
|
||||||
|
daily_chat_limit=10,
|
||||||
|
daily_chat_used=10,
|
||||||
|
daily_chat_reset_date=date(2020, 1, 1),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
prepared = ChatService.prepare_daily_quota(db, user)
|
||||||
|
assert prepared.daily_chat_used == 0
|
||||||
|
assert prepared.daily_chat_reset_date > date(2020, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_queued_chat_reports_position_and_completes(monkeypatch):
|
||||||
|
config = ChatQueueConfig(1, 10, 10, 60)
|
||||||
|
queued = ChatQueueLease("memory", QueueRequest("queued", token=object(), position=2, active_count=1, waiting_count=2))
|
||||||
|
acquired = ChatQueueLease("memory", QueueRequest("acquired", token=queued.request.token, active_count=1))
|
||||||
|
released = False
|
||||||
|
|
||||||
|
async def request_slot(_config):
|
||||||
|
return queued
|
||||||
|
|
||||||
|
async def wait_slot(_lease, _config):
|
||||||
|
return acquired
|
||||||
|
|
||||||
|
async def release_slot(_lease):
|
||||||
|
nonlocal released
|
||||||
|
released = True
|
||||||
|
|
||||||
|
async def stream_answer(*_args, **_kwargs):
|
||||||
|
yield "回答"
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat, "load_chat_queue_config", lambda _db: config)
|
||||||
|
monkeypatch.setattr(chat, "request_chat_slot", request_slot)
|
||||||
|
monkeypatch.setattr(chat, "wait_for_chat_slot", wait_slot)
|
||||||
|
monkeypatch.setattr(chat, "release_chat_slot", release_slot)
|
||||||
|
monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer)
|
||||||
|
|
||||||
|
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})()
|
||||||
|
async def collect_events():
|
||||||
|
return [item async for item in chat._chat_stream(payload, object(), object())]
|
||||||
|
|
||||||
|
events = asyncio.run(collect_events())
|
||||||
|
decoded = [json.loads(item.removeprefix("data: ")) for item in events if item != "data: [DONE]\n\n"]
|
||||||
|
assert decoded[0]["type"] == "queued"
|
||||||
|
assert any(item["type"] == "content" and item["content"] == "回答" for item in decoded)
|
||||||
|
assert released is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_system_config_never_returns_plaintext():
|
||||||
|
from app.api.admin_settings import _config_dict
|
||||||
|
|
||||||
|
config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret")
|
||||||
|
result = _config_dict(config)
|
||||||
|
assert result["configValue"] == MASKED_SECRET
|
||||||
@@ -15,6 +15,9 @@ print(f"OpenAPI paths: {len(paths)}")
|
|||||||
assert len(paths) >= 32
|
assert len(paths) >= 32
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
echo "== 后端关键测试 =="
|
||||||
|
./.venv/bin/python -m pytest -q
|
||||||
|
|
||||||
echo "== Alembic MySQL 静态 SQL 检查 =="
|
echo "== Alembic MySQL 静态 SQL 检查 =="
|
||||||
./.venv/bin/alembic upgrade head --sql >/tmp/ai_kb_v2_migration.sql
|
./.venv/bin/alembic upgrade head --sql >/tmp/ai_kb_v2_migration.sql
|
||||||
wc -l /tmp/ai_kb_v2_migration.sql
|
wc -l /tmp/ai_kb_v2_migration.sql
|
||||||
@@ -31,4 +34,16 @@ echo "== Docker Compose 配置检查 =="
|
|||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
docker compose -f docker-compose.dev.yml config --quiet
|
docker compose -f docker-compose.dev.yml config --quiet
|
||||||
|
|
||||||
|
echo "== 生产 Docker Compose 配置检查 =="
|
||||||
|
MYSQL_ROOT_PASSWORD=test-root \
|
||||||
|
MYSQL_PASSWORD=test-db \
|
||||||
|
REDIS_PASSWORD=test-redis \
|
||||||
|
DATABASE_URL='mysql+pymysql://ai_kb:test-db@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4' \
|
||||||
|
REDIS_URL='redis://:test-redis@redis:6379/0' \
|
||||||
|
JWT_SECRET_KEY='test-jwt-secret-at-least-32-characters' \
|
||||||
|
CONFIG_ENCRYPTION_KEY='MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=' \
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD='Strong-Test-Password-2026' \
|
||||||
|
docker compose -f docker-compose.prod.yml config --quiet
|
||||||
|
|
||||||
echo "阶段六自动验证完成"
|
echo "阶段六自动验证完成"
|
||||||
|
|||||||
Reference in New Issue
Block a user