feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
270 lines
9.1 KiB
Python
270 lines
9.1 KiB
Python
import json
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine, func, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.application.feishu.events import FeishuEventService
|
|
from app.core.config import get_settings
|
|
from app.core.database import Base
|
|
from app.modules.audit.models import AuditLog
|
|
from app.modules.feishu import long_connection
|
|
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
|
from app.modules.feishu.constants import FeishuEventSource
|
|
from app.modules.feishu.models import FeishuAppTicket, FeishuEventReceipt
|
|
|
|
|
|
@pytest.fixture
|
|
def session_factory(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> Iterator[sessionmaker[Session]]:
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
|
|
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
|
|
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
|
|
get_settings.cache_clear()
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[
|
|
AuditLog.__table__,
|
|
FeishuEventReceipt.__table__,
|
|
FeishuAppTicket.__table__,
|
|
],
|
|
)
|
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
try:
|
|
yield factory
|
|
finally:
|
|
Base.metadata.drop_all(
|
|
engine,
|
|
tables=[
|
|
FeishuAppTicket.__table__,
|
|
FeishuEventReceipt.__table__,
|
|
AuditLog.__table__,
|
|
],
|
|
)
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def _v2_ticket_event(
|
|
event_id: str,
|
|
ticket: str,
|
|
*,
|
|
app_id: str = "cli-ticket-app",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema": "2.0",
|
|
"header": {
|
|
"event_id": event_id,
|
|
"event_type": "app_ticket",
|
|
"token": "ticket-token",
|
|
"app_id": app_id,
|
|
},
|
|
"event": {"app_ticket": ticket},
|
|
}
|
|
|
|
|
|
def _v1_ticket_event(
|
|
event_id: str,
|
|
ticket: str,
|
|
*,
|
|
app_id: str = "cli-ticket-app",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"ts": "1785081600.000",
|
|
"uuid": event_id,
|
|
"token": "ticket-token",
|
|
"type": "app_ticket",
|
|
"event": {
|
|
"app_id": app_id,
|
|
"app_ticket": ticket,
|
|
},
|
|
}
|
|
|
|
|
|
def test_verified_ticket_is_deduplicated_rotated_and_never_leaked(
|
|
session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
first_ticket = "ticket-secret-first"
|
|
rotated_ticket = "ticket-secret-rotated"
|
|
with session_factory() as db:
|
|
service = FeishuEventService(db)
|
|
first = service._handle_verified_event(
|
|
_v2_ticket_event("ticket-event-1", first_ticket),
|
|
source=FeishuEventSource.WEBHOOK,
|
|
)
|
|
stored = db.scalar(select(FeishuAppTicket))
|
|
assert stored is not None
|
|
stored_id = stored.id
|
|
first_received_at = stored.received_at
|
|
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
|
|
assert first_ticket not in json.dumps(first, ensure_ascii=False)
|
|
|
|
duplicate_payload = _v2_ticket_event(
|
|
"ticket-event-1",
|
|
"ticket-secret-duplicate-must-not-win",
|
|
)
|
|
duplicate = service._handle_verified_event(
|
|
duplicate_payload,
|
|
source=FeishuEventSource.LONG_CONNECTION,
|
|
)
|
|
assert duplicate["duplicate"] is True
|
|
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
|
|
|
|
rotated = service._handle_verified_event(
|
|
_v2_ticket_event("ticket-event-2", rotated_ticket),
|
|
source=FeishuEventSource.LONG_CONNECTION,
|
|
)
|
|
db.expire_all()
|
|
current = db.scalar(select(FeishuAppTicket))
|
|
assert current is not None
|
|
assert current.id == stored_id
|
|
assert current.app_ticket == rotated_ticket
|
|
assert current.received_at >= first_received_at
|
|
assert db.scalar(select(func.count()).select_from(FeishuAppTicket)) == 1
|
|
assert db.scalar(select(func.count()).select_from(FeishuEventReceipt)) == 2
|
|
assert rotated_ticket not in json.dumps(rotated, ensure_ascii=False)
|
|
|
|
audits = list(db.execute(select(AuditLog)).scalars())
|
|
assert len(audits) == 2
|
|
serialized_audits = json.dumps(
|
|
[
|
|
{
|
|
"request": item.request_payload,
|
|
"response": item.response_payload,
|
|
"target": item.target_id,
|
|
}
|
|
for item in audits
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
assert first_ticket not in serialized_audits
|
|
assert rotated_ticket not in serialized_audits
|
|
assert "ticket-secret-duplicate-must-not-win" not in serialized_audits
|
|
|
|
|
|
def test_only_verified_matching_app_ticket_events_can_write(
|
|
session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with session_factory() as db:
|
|
service = FeishuEventService(db)
|
|
unverified = _v2_ticket_event("unverified-ticket", "unverified-secret")
|
|
unverified["header"]["token"] = "invalid-token"
|
|
with pytest.raises(HTTPException) as unverified_error:
|
|
service.handle_event(unverified, source=FeishuEventSource.WEBHOOK)
|
|
assert unverified_error.value.status_code == 401
|
|
|
|
with pytest.raises(HTTPException) as mismatch_error:
|
|
service._handle_verified_event(
|
|
_v2_ticket_event(
|
|
"wrong-app-ticket",
|
|
"wrong-app-secret",
|
|
app_id="cli-other-app",
|
|
),
|
|
source=FeishuEventSource.WEBHOOK,
|
|
)
|
|
assert mismatch_error.value.status_code == 401
|
|
|
|
missing_ticket = _v2_ticket_event("missing-ticket", "")
|
|
with pytest.raises(HTTPException) as missing_error:
|
|
service._handle_verified_event(
|
|
missing_ticket,
|
|
source=FeishuEventSource.WEBHOOK,
|
|
)
|
|
assert missing_error.value.status_code == 400
|
|
|
|
not_ticket_event = _v2_ticket_event("ordinary-event", "must-not-store")
|
|
not_ticket_event["header"]["event_type"] = "im.message.receive_v1"
|
|
result = service._handle_verified_event(
|
|
not_ticket_event,
|
|
source=FeishuEventSource.WEBHOOK,
|
|
auto_reply=False,
|
|
)
|
|
assert result["handled"] is False
|
|
assert db.scalar(select(FeishuAppTicket)) is None
|
|
|
|
|
|
def test_v1_app_ticket_payload_uses_uuid_receipt(
|
|
session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
ticket = "v1-ticket-secret"
|
|
with session_factory() as db:
|
|
result = FeishuEventService(db).handle_event(
|
|
_v1_ticket_event("v1-ticket-uuid", ticket),
|
|
source=FeishuEventSource.WEBHOOK,
|
|
)
|
|
|
|
receipt = db.scalar(select(FeishuEventReceipt))
|
|
assert result == {"ok": True, "handled": True}
|
|
assert receipt is not None
|
|
assert receipt.event_id == "v1-ticket-uuid"
|
|
assert receipt.event_key == "cli-ticket-app:app_ticket:v1-ticket-uuid"
|
|
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == ticket
|
|
assert ticket not in json.dumps(result, ensure_ascii=False)
|
|
|
|
|
|
def test_long_connection_registers_custom_app_ticket_handler(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
registrations: dict[str, Any] = {}
|
|
|
|
class FakeBuilder:
|
|
def register_p2_im_message_receive_v1(self, handler: Any) -> "FakeBuilder":
|
|
registrations["message"] = handler
|
|
return self
|
|
|
|
def register_p1_customized_event(
|
|
self,
|
|
event_type: str,
|
|
handler: Any,
|
|
) -> "FakeBuilder":
|
|
registrations[event_type] = handler
|
|
return self
|
|
|
|
def build(self) -> "FakeBuilder":
|
|
return self
|
|
|
|
class FakeDispatcherHandler:
|
|
@staticmethod
|
|
def builder(encrypt_key: str, verification_token: str) -> FakeBuilder:
|
|
registrations["builder_args"] = (encrypt_key, verification_token)
|
|
return FakeBuilder()
|
|
|
|
class FakeClient:
|
|
def __init__(self, **kwargs: Any):
|
|
registrations["client_kwargs"] = kwargs
|
|
|
|
def start(self) -> None:
|
|
registrations["started"] = True
|
|
|
|
fake_lark = SimpleNamespace(
|
|
EventDispatcherHandler=FakeDispatcherHandler,
|
|
LogLevel=SimpleNamespace(WARNING="warning"),
|
|
ws=SimpleNamespace(Client=FakeClient),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "lark_oapi", fake_lark)
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
|
|
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
|
|
get_settings.cache_clear()
|
|
try:
|
|
long_connection.run_long_connection()
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
assert registrations["app_ticket"] is long_connection._handle_app_ticket_event
|
|
assert registrations["message"] is long_connection._handle_message_event
|
|
assert registrations["started"] is True
|