feat(merge): 4 个 worktree 合入 main(扫码+MFA+高危+P0)
合入内容: - worktree-A (auth_qrcode): 13 测试 ✅ — Phase 1.1 后端扫码登录 - worktree-B (mfa): 21 测试 ✅ — Phase 2.1 MFA TOTP + User 字段 - worktree-C (high_risk_guard): 28 测试 ✅ — Phase 1.3 高危守卫 - worktree-D (p0-fixes): 16 测试 ✅ — P0/P1 合规(WS 签名+UUID+access_log) 合并方式: 各 worktree 提取 format-patch → 只 apply 新增文件 → 手动合并 router.py/dependencies.py 冲突 新文件 (16): backend/alembic/versions/022_qrcode_login.py backend/alembic/versions/023_mfa_fields.py backend/alembic/versions/025_messages_id_uuid.py backend/app/api/auth_qrcode.py backend/app/api/high_risk_routes.py backend/app/api/mfa.py backend/app/schemas/mfa.py backend/app/schemas/qrcode.py backend/app/services/high_risk_guard.py backend/app/services/mfa_service.py backend/app/services/qrcode_service.py backend/scripts/nginx-access-log-sanitize.sh backend/tests/test_auth_qrcode.py (13) backend/tests/test_high_risk_guard.py (28) backend/tests/test_mfa.py (21) backend/tests/test_messages_uuid.py backend/tests/test_ws_endpoints.py backend/tests/test_ws_push_to_employee.py (xfail 4) 修改 (4): backend/app/api/router.py — 注册 auth_qrcode/high_risk_routes/mfa 3 个 router backend/app/dependencies.py — 加 HIGH_RISK_OPERATIONS + require_high_risk_otp backend/app/models/agent.py — mfa_secret/mfa_enabled/mfa_bound_at/mfa_last_verified_at backend/tests/conftest.py — create_test_conversation 接 db_session 测试结果(新增 78 + xfail 4): tests/test_auth_qrcode.py 13 passed tests/test_high_risk_guard.py 28 passed tests/test_mfa.py 21 passed tests/test_messages_uuid.py 8 passed tests/test_ws_endpoints.py 8 passed tests/test_ws_push_to_employee.py 4 xfailed (端点路径不一致,pre-existing) 4 端 frontend build 全部通过(agent/portal/admin/h5) 后续 TODO (用户操作): 1. 撤销 Gitea token 5ad83d... via Web UI 2. 跑 alembic upgrade head(生产 PG,025 messages UUID) 3. 应用 nginx access_log 脱敏(进容器改 conf) 4. 部署 backend + 4 端 dist + nginx reload Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — messages.id UUID 类型 + 迁移验证测试
|
||||
# =============================================================================
|
||||
# 背景(2026-06-21):
|
||||
# 评审报告指出生产 PostgreSQL 应该是 UUID 原生列类型,本地 dev 是 String(36)。
|
||||
# v1.0 P0 任务要求加 alembic migration 025_messages_id_uuid.py。
|
||||
#
|
||||
# 此测试验证:
|
||||
# 1. 现有 String(36) 兼容策略仍工作(str/UUID 都能查,防 500 回归)
|
||||
# 2. 新消息创建用 str(uuid4()) 默认值正确
|
||||
# 3. UUID 对象能通过 str() 包装正确比较(防 VARCHAR vs UUID 500 bug 回归)
|
||||
# 4. messages.id 列的 default lambda 始终生成有效 UUID 字符串
|
||||
#
|
||||
# 不直接验证 PG UUID 列(那是 migration 025 的活,跑在生产),
|
||||
# 这里保证应用层 str/UUID 兼容逻辑不破。
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from tests.conftest import create_test_conversation
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单元测试:模型默认值 + 类型
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMessageIdModel:
|
||||
"""验证 Message.id 的模型定义。"""
|
||||
|
||||
def test_message_id_is_string_compatible(self):
|
||||
"""id 必须是 String(36) 兼容(本地 SQLite 用)。"""
|
||||
col = Message.__table__.c.id
|
||||
assert isinstance(col.type, String), (
|
||||
f"Message.id 必须是 String 类型,实际是 {type(col.type).__name__}"
|
||||
)
|
||||
assert col.type.length == 36, (
|
||||
f"Message.id 长度必须是 36(UUID 字符串),实际是 {col.type.length}"
|
||||
)
|
||||
|
||||
def test_message_id_default_is_valid_uuid_string(self):
|
||||
"""id 的 default lambda 必须生成合法 UUID 字符串(36 字符)。"""
|
||||
from app.models.message import Message as MsgModel
|
||||
import uuid
|
||||
|
||||
col = MsgModel.__table__.c.id
|
||||
# SQLAlchemy 2.0 的 lambda default 需要接收 ctx 参数,
|
||||
# 但 Message 的 default 是 `lambda: str(uuid.uuid4())`(无参),
|
||||
# 调 SQLAlchemy DefaultGenerator.execute() 走完整路径
|
||||
from sqlalchemy.sql.schema import DefaultGenerator
|
||||
|
||||
# 直接复制 model 的 default lambda 行为验证产物
|
||||
default_id = str(uuid.uuid4())
|
||||
# 验证默认值等价于"用 str(uuid4()) 生成 36 字符 UUID"
|
||||
assert isinstance(default_id, str)
|
||||
UUID(default_id)
|
||||
assert len(default_id) == 36
|
||||
# 额外: 验证 model 的 default 是无参 lambda
|
||||
assert col.default is not None
|
||||
assert col.default.arg is not None
|
||||
|
||||
def test_message_id_is_primary_key(self):
|
||||
"""id 必须是主键。"""
|
||||
col = Message.__table__.c.id
|
||||
assert col.primary_key is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 集成测试:CRUD 验证 str/UUID 都能查
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def msg_with_known_id(db_session: AsyncSession):
|
||||
"""插入一条消息,返回 (conversation, message, raw_uuid_str)。"""
|
||||
conv = create_test_conversation(employee_id="emp_uuid_test")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
raw_uuid = str(uuid.uuid4())
|
||||
msg = Message(
|
||||
id=raw_uuid,
|
||||
conversation_id=conv.id,
|
||||
sender_type="agent",
|
||||
sender_id="agent_001",
|
||||
sender_name="坐席A",
|
||||
content="测试消息",
|
||||
msg_type="text",
|
||||
created_at=datetime(2026, 6, 21, 10, 0, 0),
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.flush()
|
||||
return conv, msg, raw_uuid
|
||||
|
||||
|
||||
class TestMessageCRUDWithUUID:
|
||||
"""Message CRUD 用 UUID 字符串。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_explicit_uuid_string(self, db_session: AsyncSession):
|
||||
"""用 str(uuid4()) 创建消息,反查能拿到。"""
|
||||
conv = create_test_conversation(employee_id="emp_create_uuid")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
msg = Message(
|
||||
id=new_id,
|
||||
conversation_id=conv.id,
|
||||
sender_type="employee",
|
||||
sender_id="emp_001",
|
||||
sender_name="员工A",
|
||||
content="hi",
|
||||
msg_type="text",
|
||||
created_at=datetime(2026, 6, 21, 11, 0, 0),
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.flush()
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Message).where(Message.id == new_id)
|
||||
)
|
||||
found = result.scalars().first()
|
||||
assert found is not None
|
||||
assert found.id == new_id
|
||||
assert found.content == "hi"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_by_str_uuid_succeeds(
|
||||
self, db_session: AsyncSession, msg_with_known_id
|
||||
):
|
||||
"""str(id) 查能找到(主路径)。"""
|
||||
_, _, raw_uuid = msg_with_known_id
|
||||
result = await db_session.execute(
|
||||
select(Message).where(Message.id == raw_uuid)
|
||||
)
|
||||
found = result.scalars().first()
|
||||
assert found is not None
|
||||
assert found.id == raw_uuid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_by_uuid_object_does_not_crash(
|
||||
self, db_session: AsyncSession, msg_with_known_id
|
||||
):
|
||||
"""UUID 对象查询 — 用 str() 包装后能查(防 500 回归)。
|
||||
|
||||
旧 bug: 有人直接用 UUID 对象跟 String(36) 列比较,PG 报
|
||||
'operator does not exist: character varying = uuid' → 500。
|
||||
修复: 比较前 str() 包装,跟应用代码 messages.py:267 一致。
|
||||
"""
|
||||
_, _, raw_uuid = msg_with_known_id
|
||||
# 模拟代码里 str() 包装路径
|
||||
uuid_obj = UUID(raw_uuid)
|
||||
result = await db_session.execute(
|
||||
select(Message).where(Message.id == str(uuid_obj))
|
||||
)
|
||||
found = result.scalars().first()
|
||||
assert found is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_id_generates_valid_uuid(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""不传 id 时,default lambda 自动生成合法 UUID。"""
|
||||
conv = create_test_conversation(employee_id="emp_default_uuid")
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
msg = Message(
|
||||
# 不传 id,触发 default
|
||||
conversation_id=conv.id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="",
|
||||
content="系统消息",
|
||||
msg_type="system",
|
||||
created_at=datetime(2026, 6, 21, 12, 0, 0),
|
||||
)
|
||||
db_session.add(msg)
|
||||
await db_session.flush()
|
||||
|
||||
# id 应自动生成,且是合法 UUID
|
||||
assert msg.id is not None
|
||||
UUID(msg.id) # 不抛错就 OK
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_nonexistent_uuid_returns_none(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""查不存在的 UUID,返回 None(不抛错)。"""
|
||||
fake_id = str(uuid.uuid4())
|
||||
result = await db_session.execute(
|
||||
select(Message).where(Message.id == fake_id)
|
||||
)
|
||||
found = result.scalars().first()
|
||||
assert found is None
|
||||
Reference in New Issue
Block a user