```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
487
tests/test_feishu_event_identity.py
Normal file
487
tests/test_feishu_event_identity.py
Normal file
@@ -0,0 +1,487 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
from app.application.feishu.events import FeishuEventService
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu.constants import (
|
||||
FeishuCommandName,
|
||||
FeishuEventSource,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.routes import router as feishu_router
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuMention
|
||||
from app.modules.feishu_users.services import (
|
||||
FeishuIdentityService,
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
|
||||
monkeypatch.setenv("FEISHU_ADMIN_IDENTITIES", "tenant-a:ou-admin")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verified-token")
|
||||
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__,
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
Base.metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
FeishuEventReceipt.__table__,
|
||||
AuditLog.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _message_event(
|
||||
*,
|
||||
event_id: str,
|
||||
text: str,
|
||||
tenant_key: str | None = "tenant-a",
|
||||
open_id: str | None = "ou-user",
|
||||
chat_id: str = "oc-chat",
|
||||
chat_type: str = "p2p",
|
||||
mentions: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
header: dict[str, Any] = {
|
||||
"event_id": event_id,
|
||||
"event_type": "im.message.receive_v1",
|
||||
"token": "verified-token",
|
||||
}
|
||||
if tenant_key is not None:
|
||||
header["tenant_key"] = tenant_key
|
||||
sender_id: dict[str, str] = {"user_id": "legacy-user-id"}
|
||||
if open_id is not None:
|
||||
sender_id["open_id"] = open_id
|
||||
message: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"chat_type": chat_type,
|
||||
"message_id": f"om-{event_id}",
|
||||
"message_type": "text",
|
||||
"content": json.dumps({"text": text}, ensure_ascii=False),
|
||||
}
|
||||
if mentions is not None:
|
||||
message["mentions"] = mentions
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"header": header,
|
||||
"event": {
|
||||
"sender": {"sender_id": sender_id},
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mention(
|
||||
key: str,
|
||||
open_id: str,
|
||||
*,
|
||||
name: str,
|
||||
tenant_key: str = "tenant-a",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"key": key,
|
||||
"name": name,
|
||||
"tenant_key": tenant_key,
|
||||
"id": {
|
||||
"open_id": open_id,
|
||||
"union_id": f"on-{open_id}",
|
||||
"user_id": f"u-{open_id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_verified_event_requires_tenant_and_open_id_without_registering(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
missing_tenant = FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="missing-tenant",
|
||||
text="risk",
|
||||
tenant_key=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
missing_open_id = FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="missing-open",
|
||||
text="risk",
|
||||
open_id=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert missing_tenant["result"]["command"] == "permission_denied"
|
||||
assert missing_open_id["result"]["command"] == "permission_denied"
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
denial_logs = list(
|
||||
db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == FeishuUserAuditAction.PERMISSION_DENIED
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(denial_logs) == 2
|
||||
assert all(item.actor == "feishu" for item in denial_logs)
|
||||
|
||||
|
||||
def test_unverified_event_cannot_create_identity(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
payload = _message_event(
|
||||
event_id="invalid-token",
|
||||
text="问 你好",
|
||||
open_id="ou-unverified",
|
||||
)
|
||||
payload["header"]["token"] = "invalid-token"
|
||||
with session_factory() as db:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
|
||||
|
||||
def test_group_event_uses_sender_principal_and_structured_context(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_handle_text(
|
||||
self: FeishuCommandService,
|
||||
text: str,
|
||||
chat_id: str | None = None,
|
||||
actor: str = "feishu",
|
||||
auto_reply: bool = True,
|
||||
principal: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
captured.update(
|
||||
{
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"actor": actor,
|
||||
"principal": principal,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"command": "ai_ask",
|
||||
"reply_type": "text",
|
||||
"title": "test",
|
||||
"content": "ok",
|
||||
"provider_response": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
|
||||
payload = _message_event(
|
||||
event_id="group-principal",
|
||||
text="@_bot 问 项目状态",
|
||||
open_id="ou-sender",
|
||||
chat_id="oc-group",
|
||||
chat_type="group",
|
||||
mentions=[_mention("@_target", "ou-target", name="目标用户")],
|
||||
)
|
||||
with session_factory() as db:
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=False,
|
||||
)
|
||||
principal = captured["principal"]
|
||||
|
||||
assert result["handled"] is True
|
||||
assert principal.open_id == "ou-sender"
|
||||
assert principal.chat_id == "oc-group"
|
||||
assert principal.chat_type == "group"
|
||||
assert principal.mentions[0].open_id == "ou-target"
|
||||
assert captured["actor"] == principal.user_code
|
||||
assert captured["chat_id"] == "oc-group"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"日报",
|
||||
"项目资金 P-001",
|
||||
"风险",
|
||||
"学习公司规则:所有人共享",
|
||||
"设为管理员 @_target",
|
||||
],
|
||||
)
|
||||
def test_ordinary_user_cannot_run_company_commands(
|
||||
session_factory: sessionmaker[Session],
|
||||
command: str,
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id=f"ou-{abs(hash(command))}",
|
||||
)
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
command,
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
|
||||
assert "公司级功能" in result["content"]
|
||||
|
||||
|
||||
def test_disabled_user_is_rejected_before_command_execution(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-disabled",
|
||||
)
|
||||
FeishuUserManagementService(db).update_user(
|
||||
principal.user_code,
|
||||
changes={"status": FeishuUserStatus.DISABLED},
|
||||
actor="service-admin",
|
||||
)
|
||||
disabled = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-disabled",
|
||||
)
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
"问 你好",
|
||||
principal=disabled,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
|
||||
assert "已停用" in result["content"]
|
||||
|
||||
|
||||
def test_admin_command_uses_target_mention_and_ignores_bot_mention(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
payload = _message_event(
|
||||
event_id="admin-promote",
|
||||
text="@_bot 设为管理员 @_target",
|
||||
open_id="ou-admin",
|
||||
chat_id="oc-group",
|
||||
chat_type="group",
|
||||
mentions=[
|
||||
_mention("@_bot", "ou-bot", name="机器人"),
|
||||
_mention("@_target", "ou-target", name="张三"),
|
||||
],
|
||||
)
|
||||
with session_factory() as db:
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
target = FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-target",
|
||||
)
|
||||
|
||||
assert result["result"]["command"] == FeishuCommandName.USER_SET_ADMIN
|
||||
assert "张三 已设为管理员" in result["result"]["content"]
|
||||
assert target is not None
|
||||
assert target.role == FeishuUserRole.ADMIN
|
||||
assert target.union_id == "on-ou-target"
|
||||
|
||||
|
||||
def test_admin_command_rejects_text_identity_cross_tenant_and_last_admin(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
admin = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
admin = replace(admin, chat_id="oc-group", chat_type="group")
|
||||
service = FeishuCommandService(db)
|
||||
|
||||
forged = service.handle_text(
|
||||
"设为管理员 @ou-forged",
|
||||
principal=admin,
|
||||
auto_reply=False,
|
||||
)
|
||||
assert forged["command"] == FeishuCommandName.USER_SET_ADMIN
|
||||
assert "真实的飞书 @用户" in forged["content"]
|
||||
assert (
|
||||
FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-forged",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
cross_tenant = service.handle_text(
|
||||
"设为管理员 @_target",
|
||||
principal=replace(
|
||||
admin,
|
||||
mentions=(
|
||||
FeishuMention(
|
||||
key="@_target",
|
||||
name="其他租户用户",
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-other",
|
||||
),
|
||||
),
|
||||
),
|
||||
auto_reply=False,
|
||||
)
|
||||
assert "当前租户" in cross_tenant["content"]
|
||||
assert (
|
||||
FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-other",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
self_demote = service.handle_text(
|
||||
"设为普通用户 @_self",
|
||||
principal=replace(
|
||||
admin,
|
||||
mentions=(
|
||||
FeishuMention(
|
||||
key="@_self",
|
||||
name="管理员",
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
),
|
||||
),
|
||||
),
|
||||
auto_reply=False,
|
||||
)
|
||||
assert "最后一个有效管理员" in self_demote["content"]
|
||||
stored_admin = FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
assert stored_admin is not None
|
||||
assert stored_admin.role == FeishuUserRole.ADMIN
|
||||
|
||||
|
||||
def test_preview_cannot_forge_feishu_principal_when_features_enabled(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_KEY", "service-key")
|
||||
monkeypatch.setenv("API_ACTOR", "service-principal")
|
||||
get_settings.cache_clear()
|
||||
app = FastAPI()
|
||||
app.include_router(feishu_router, prefix="/api/v1/integrations/feishu")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/integrations/feishu/commands/preview",
|
||||
headers={"X-API-Key": "service-key"},
|
||||
json={
|
||||
"text": "日报",
|
||||
"actor": "ou-admin",
|
||||
"auto_reply": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["command"] == "permission_denied"
|
||||
with session_factory() as db:
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
|
||||
|
||||
def test_disabled_feature_flag_preserves_legacy_actor_flow(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_handle_text(
|
||||
self: FeishuCommandService,
|
||||
text: str,
|
||||
chat_id: str | None = None,
|
||||
actor: str = "feishu",
|
||||
auto_reply: bool = True,
|
||||
principal: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
captured["actor"] = actor
|
||||
captured["principal"] = principal
|
||||
return {
|
||||
"command": "fallback_ai",
|
||||
"reply_type": "text",
|
||||
"title": "test",
|
||||
"content": "ok",
|
||||
"provider_response": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
|
||||
with session_factory() as db:
|
||||
FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="legacy-flow",
|
||||
text="hello",
|
||||
tenant_key=None,
|
||||
open_id=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert captured["actor"] == "legacy-user-id"
|
||||
assert captured["principal"] is None
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
Reference in New Issue
Block a user