- 认证:Web Session(__Host- Cookie、HttpOnly/Secure/SameSite=Lax、CSPRNG opaque token 数据库只存哈希)、CSRF 双提交、Origin 校验、dev 登录/登出端点;未认证 401、缺 CSRF 403
- 中间件:Hunjing-Request-ID 回显/生成(request_ UUIDv4)+ W3C traceparent 校验透传;JSON 结构化日志与敏感头脱敏
- 迁移 2:conversation/branch/message/run/idempotency_record 五表(含检查约束与复合索引);Session 补 token/csrf 哈希列
- 垂直切片:POST /v1/conversations/{id}/messages 按 Idempotency-Key 幂等(同键同体 200 重放、异体 409),同事务原子写 Message+Run+Audit+Outbox+幂等记录;ContentBlock text/markdown 判别联合(未知类型 422)
- SSE:GET /v1/runs/{id}/events 返回快照与终止状态事件 envelope
- 测试 26 项全绿:IDOR 跨域 404 fail-closed、失败注入回滚无孤儿(Run 崩溃后 message/run/audit/outbox/幂等全零)、SSE 事件序、auth 边界;ruff/mypy strict 干净
- 修复真实问题:asyncpg 跨事件循环(anyio/pytest-asyncio 双 loop、模块级 engine 改为 app 实例持有)、httpx 拒存 http 下 Secure Cookie
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
"""首批模型的集成测试:CRUD、约束与外键行为(运行于 PostgreSQL)。"""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.core.ids import new_id
|
|
from app.models import AuditEvent, OutboxEvent, PersonalDomain, ServiceAccount, Session, User
|
|
|
|
|
|
async def _make_user(db: async_sessionmaker[AsyncSession], email: str = "a@example.com") -> User:
|
|
async with db() as session:
|
|
user = User(email=email, display_name="测试用户")
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|
|
|
|
|
|
async def test_user_crud_and_id_prefix(db: async_sessionmaker[AsyncSession]) -> None:
|
|
user = await _make_user(db)
|
|
assert user.id.startswith("user_")
|
|
async with db() as session:
|
|
loaded = await session.get(User, user.id)
|
|
assert loaded is not None
|
|
assert loaded.email == "a@example.com"
|
|
assert loaded.created_at.tzinfo is not None # aware UTC
|
|
|
|
|
|
async def test_duplicate_email_rejected_by_unique(db: async_sessionmaker[AsyncSession]) -> None:
|
|
await _make_user(db)
|
|
async with db() as session:
|
|
session.add(User(email="a@example.com", display_name="重复"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_personal_domain_requires_user(db: async_sessionmaker[AsyncSession]) -> None:
|
|
async with db() as session:
|
|
session.add(PersonalDomain(user_id=new_id("user_"), name="镜域"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_delete_user_restricted_by_domain(db: async_sessionmaker[AsyncSession]) -> None:
|
|
user = await _make_user(db)
|
|
async with db() as session:
|
|
session.add(PersonalDomain(user_id=user.id, name="镜域"))
|
|
await session.commit()
|
|
async with db() as session:
|
|
loaded = await session.get(User, user.id)
|
|
assert loaded is not None
|
|
await session.delete(loaded)
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_session_cascades_with_user(db: async_sessionmaker[AsyncSession]) -> None:
|
|
user = await _make_user(db)
|
|
async with db() as session:
|
|
session.add(
|
|
Session(
|
|
id="opaque-session-token",
|
|
user_id=user.id,
|
|
token_hash="sha256:opaque-session-token",
|
|
csrf_token_hash="sha256:csrf",
|
|
expires_at=datetime.now(UTC) + timedelta(days=7),
|
|
)
|
|
)
|
|
await session.commit()
|
|
async with db() as session:
|
|
loaded = await session.get(User, user.id)
|
|
assert loaded is not None
|
|
await session.delete(loaded)
|
|
await session.commit()
|
|
async with db() as session:
|
|
assert (await session.scalars(select(Session))).all() == []
|
|
|
|
|
|
async def test_service_account_unique_domain_name(db: async_sessionmaker[AsyncSession]) -> None:
|
|
user = await _make_user(db)
|
|
async with db() as session:
|
|
domain = PersonalDomain(user_id=user.id, name="镜域")
|
|
session.add(domain)
|
|
await session.commit()
|
|
await session.refresh(domain)
|
|
async with db() as session:
|
|
session.add(
|
|
ServiceAccount(
|
|
domain_id=domain.id, name="ci", key_prefix="hjk_ci", key_hash="sha256:..."
|
|
)
|
|
)
|
|
await session.commit()
|
|
session.add(
|
|
ServiceAccount(
|
|
domain_id=domain.id, name="ci", key_prefix="hjk_ci2", key_hash="sha256:..."
|
|
)
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_outbox_status_check_constraint(db: async_sessionmaker[AsyncSession]) -> None:
|
|
async with db() as session:
|
|
session.add(
|
|
OutboxEvent(
|
|
aggregate_type="user",
|
|
aggregate_id=new_id("user_"),
|
|
event_type="user.created",
|
|
payload={},
|
|
status="bogus",
|
|
)
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_audit_and_outbox_same_transaction(db: async_sessionmaker[AsyncSession]) -> None:
|
|
"""契约第 4.4 节:权威状态、AuditEvent 与 OutboxEvent 同事务提交。"""
|
|
async with db() as session:
|
|
user = User(email="tx@example.com", display_name="事务")
|
|
session.add(user)
|
|
await session.flush() # 取得 user.id,事务尚未提交
|
|
session.add(
|
|
AuditEvent(
|
|
actor_type="system",
|
|
actor_id="system",
|
|
action="user.created",
|
|
aggregate_type="user",
|
|
aggregate_id=user.id,
|
|
)
|
|
)
|
|
session.add(
|
|
OutboxEvent(
|
|
aggregate_type="user",
|
|
aggregate_id=user.id,
|
|
event_type="user.created",
|
|
payload={"user_id": user.id},
|
|
)
|
|
)
|
|
await session.commit()
|
|
assert (await session.scalars(select(OutboxEvent))).one().status == "pending"
|