feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import UniqueConstraint, create_engine, func, select, text
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from app.application.events import EventDispatchService
|
|
from app.application.scheduling import create_scheduler
|
|
from app.core.config import get_settings
|
|
from app.core.database import Base
|
|
from app.core.utils.time import utc_now
|
|
from app.main import app
|
|
from app.modules.ai_agent.constants import AIContextKey
|
|
from app.modules.ai_agent.service import AIService
|
|
from app.modules.audit.constants import AuditSource
|
|
from app.modules.events.constants import EventStatus
|
|
from app.modules.events.models import DomainEvent
|
|
from app.modules.events.services import EventService
|
|
from app.modules.legacy_mysql.services import LegacyMySQLService
|
|
from app.modules.observability.constants import (
|
|
HeartbeatComponent,
|
|
ObservabilityKey,
|
|
ObservabilityStatus,
|
|
)
|
|
from app.modules.observability.models import SystemHeartbeat
|
|
from app.modules.observability.service import ObservabilityService
|
|
from app.tasks import celery_app
|
|
|
|
|
|
def _session_factory():
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
|
|
|
|
|
def test_readiness_and_metrics_degrade_when_schema_is_missing() -> None:
|
|
engine = create_engine("sqlite://")
|
|
with Session(engine) as db:
|
|
readiness = ObservabilityService(db).ready()
|
|
metrics = ObservabilityService(db).metrics()
|
|
|
|
assert readiness[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert readiness[ObservabilityKey.CHECKS][ObservabilityKey.DATABASE][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.OK
|
|
assert readiness[ObservabilityKey.CHECKS][ObservabilityKey.EVENTS][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.ERROR
|
|
assert metrics[ObservabilityKey.METRICS][ObservabilityKey.EVENTS][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.ERROR
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sql",
|
|
[
|
|
"SELECT 1; DELETE FROM projects",
|
|
"SELECT * FROM projects /* hidden operation */",
|
|
"SELECT * FROM projects -- hidden operation",
|
|
"SELECT * FROM projects FOR UPDATE",
|
|
"SELECT * INTO OUTFILE '/tmp/data' FROM projects",
|
|
],
|
|
)
|
|
def test_legacy_query_rejects_unsafe_allowlist_sql(sql: str) -> None:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
LegacyMySQLService(None)._ensure_readonly(sql)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def test_legacy_query_accepts_parameterized_select() -> None:
|
|
LegacyMySQLService(None)._ensure_readonly(
|
|
"SELECT code, name FROM projects WHERE owner = :owner LIMIT :limit"
|
|
)
|
|
|
|
|
|
def test_celery_and_scheduler_have_safe_runtime_defaults() -> None:
|
|
assert celery_app.conf.task_serializer == "json"
|
|
assert celery_app.conf.result_serializer == "json"
|
|
assert tuple(celery_app.conf.accept_content) == ("json",)
|
|
assert celery_app.conf.task_acks_late is True
|
|
assert celery_app.conf.worker_prefetch_multiplier == 1
|
|
|
|
scheduler = create_scheduler()
|
|
assert scheduler._job_defaults["coalesce"] is True
|
|
assert scheduler._job_defaults["max_instances"] == 1
|
|
|
|
|
|
def test_unknown_event_type_fails_instead_of_being_discarded() -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
event = EventService(db).emit(
|
|
event_type="unknown.event",
|
|
source="pytest",
|
|
aggregate_type="test",
|
|
aggregate_id="unknown",
|
|
idempotency_key="unknown-event",
|
|
)
|
|
event.max_attempts = 1
|
|
db.commit()
|
|
|
|
result = EventDispatchService(db).dispatch_event(
|
|
event.event_id,
|
|
worker_id="pytest",
|
|
)
|
|
|
|
assert result.status == EventStatus.FAILED
|
|
assert "Unsupported domain event type" in (result.last_error or "")
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_event_failure_rolls_back_handler_transaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
event = EventService(db).emit(
|
|
event_type="test.database_failure",
|
|
source="pytest",
|
|
aggregate_type="test",
|
|
aggregate_id="database-failure",
|
|
idempotency_key="database-failure",
|
|
)
|
|
event.max_attempts = 1
|
|
db.commit()
|
|
dispatcher = EventDispatchService(db)
|
|
|
|
def fail_with_database_error(record: DomainEvent) -> None:
|
|
_ = record
|
|
db.execute(text("SELECT * FROM missing_handler_table")).all()
|
|
|
|
monkeypatch.setattr(dispatcher, "_handle_event", fail_with_database_error)
|
|
result = dispatcher.dispatch_event(event.event_id, worker_id="pytest")
|
|
|
|
assert result.status == EventStatus.FAILED
|
|
assert result.last_error
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_retry_rejects_an_active_event_lease() -> None:
|
|
engine, factory = _session_factory()
|
|
try:
|
|
with factory() as db:
|
|
event = EventService(db).emit(
|
|
event_type="test.locked",
|
|
source="pytest",
|
|
aggregate_type="test",
|
|
aggregate_id="locked",
|
|
idempotency_key="locked-event",
|
|
)
|
|
event.locked_by = "active-worker"
|
|
event.locked_until = utc_now() + timedelta(minutes=5)
|
|
db.commit()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
EventDispatchService(db).retry_event(event.event_id)
|
|
|
|
assert exc_info.value.status_code == 409
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_heartbeat_identity_is_unique_and_expired_instances_are_ignored(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("HEARTBEAT_RETENTION_SECONDS", "3600")
|
|
get_settings.cache_clear()
|
|
engine, factory = _session_factory()
|
|
try:
|
|
constraints = SystemHeartbeat.__table__.constraints
|
|
assert any(
|
|
isinstance(item, UniqueConstraint)
|
|
and {column.name for column in item.columns} == {"component", "instance_id"}
|
|
for item in constraints
|
|
)
|
|
|
|
with factory() as db:
|
|
service = ObservabilityService(db)
|
|
service.record_heartbeat(
|
|
component=HeartbeatComponent.WORKER,
|
|
instance_id="worker-current",
|
|
)
|
|
service.record_heartbeat(
|
|
component=HeartbeatComponent.WORKER,
|
|
instance_id="worker-current",
|
|
)
|
|
db.add(
|
|
SystemHeartbeat(
|
|
component=HeartbeatComponent.WORKER,
|
|
instance_id="worker-expired",
|
|
status="ok",
|
|
last_seen_at=utc_now() - timedelta(hours=2),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
count = db.scalar(select(func.count()).select_from(SystemHeartbeat))
|
|
summary = service.heartbeat_summary()
|
|
|
|
assert count == 2
|
|
assert summary["total"] == 1
|
|
assert summary["stale"] == 0
|
|
finally:
|
|
get_settings.cache_clear()
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[
|
|
(AIContextKey.OPENCLAW_TOOL, "sessions_list"),
|
|
(AIContextKey.OPENCLAW_ACTION, "json"),
|
|
(AIContextKey.OPENCLAW_ARGS, {"limit": 1}),
|
|
(AIContextKey.OPENCLAW_SESSION_KEY, "main"),
|
|
],
|
|
)
|
|
def test_external_ai_cannot_invoke_openclaw_tools(
|
|
field: AIContextKey,
|
|
value: object,
|
|
) -> None:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
AIService(Session()).ask(
|
|
"run a tool",
|
|
context={field: value},
|
|
source=AuditSource.API,
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"]
|
|
|
|
|
|
def test_sample_requests_are_read_only() -> None:
|
|
sample = Path("scripts/sample_requests.http").read_text(encoding="utf-8")
|
|
|
|
assert "POST http://127.0.0.1:8010/api/v1/business/" not in sample
|
|
assert "/sync" not in sample
|
|
assert "/assign" not in sample
|