Files
company-ai-platform/tests/test_feishu_personalization_commands.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

487 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.feishu.commands import FeishuCommandService
from app.application.feishu.handlers import personal_data as personal_data_handler
from app.application.feishu.personal_data import PERSONAL_DATA_ERASURE_ACTION
from app.core.config import get_settings
from app.core.database import Base
from app.modules.ai_agent.constants import AIResponseKey
from app.modules.ai_agent.service import AIService
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.constants import AuditAction
from app.modules.audit.models import AuditLog
from app.modules.feishu.constants import FeishuCommandName
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import FeishuUser
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.feishu_users.services import FeishuIdentityService
from app.modules.personalization.models import UserPreference
from app.modules.personalization.services import ConversationService
from app.modules.subscriptions.models import PushSubscription
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
get_settings.cache_clear()
def _principal(
db: Session,
suffix: str,
*,
role: str = FeishuUserRole.USER,
chat_id: str | None = None,
chat_type: str = "p2p",
) -> FeishuPrincipal:
user = FeishuUser(
code=f"FSU-COMMAND-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=role,
)
db.add(user)
db.commit()
db.refresh(user)
return FeishuPrincipal.from_user(
user,
chat_id=chat_id or f"chat-{suffix}",
chat_type=chat_type,
)
def test_personal_rules_are_owner_scoped_and_company_rules_require_admin(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(db, "owner")
other = _principal(db, "other")
admin = _principal(db, "admin", role=FeishuUserRole.ADMIN)
created = FeishuCommandService(db).handle_text(
"学习规则 80回答尽量简洁",
principal=owner,
auto_reply=False,
)
denied = FeishuCommandService(db).handle_text(
"学习公司规则:所有人使用中文",
principal=other,
auto_reply=False,
)
company = FeishuCommandService(db).handle_text(
"学习公司规则:所有人使用中文",
principal=admin,
auto_reply=False,
)
records = list(
db.execute(
select(AIMemoryEntry).order_by(AIMemoryEntry.id.asc())
).scalars()
)
assert created["command"] == FeishuCommandName.RULE_CREATE
assert denied["command"] == FeishuCommandName.PERMISSION_DENIED
assert company["command"] == FeishuCommandName.RULE_CREATE
assert len(records) == 2
assert records[0].owner_id == owner.owner_id
assert records[1].owner_id is None
owner_list = FeishuCommandService(db).handle_text(
"查看规则",
principal=owner,
auto_reply=False,
)
other_list = FeishuCommandService(db).handle_text(
"查看规则",
principal=other,
auto_reply=False,
)
assert "回答尽量简洁" in owner_list["content"]
assert "回答尽量简洁" not in other_list["content"]
assert "所有人使用中文" not in owner_list["content"]
def test_preferences_and_topics_are_isolated_and_sensitive_content_is_rejected(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(db, "preference-owner")
other = _principal(db, "preference-other")
service = FeishuCommandService(db)
preference = service.handle_text(
"记住偏好 语气:简洁",
principal=owner,
auto_reply=False,
)
topic = service.handle_text(
"关注主题:人工智能",
principal=owner,
auto_reply=False,
)
code = str(preference["content"]).split("编号:", 1)[1].splitlines()[0]
cross_owner_delete = service.handle_text(
f"删除偏好 {code}",
principal=other,
auto_reply=False,
)
sensitive = service.handle_text(
"记住偏好 兴趣:我的银行卡是 123456",
principal=owner,
auto_reply=False,
)
assert preference["command"] == FeishuCommandName.PREFERENCE_SET
assert topic["command"] == FeishuCommandName.PREFERENCE_SET
assert "没有找到" in cross_owner_delete["content"]
assert "敏感" in sensitive["content"]
assert db.scalar(
select(func.count())
.select_from(UserPreference)
.where(UserPreference.owner_id == owner.owner_id)
) == 2
assert db.scalar(
select(func.count())
.select_from(UserPreference)
.where(UserPreference.owner_id == other.owner_id)
) == 0
def test_conversation_reset_only_clears_current_user_and_chat(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(
db,
"conversation-owner",
chat_id="group-current",
chat_type="group",
)
other = _principal(
db,
"conversation-other",
chat_id="group-current",
chat_type="group",
)
conversations = ConversationService(db)
conversations.record_turn(
owner.owner_id,
"group",
"group-current",
user_content="owner question",
assistant_content="owner answer",
provider_name="direct_llm",
)
conversations.record_turn(
owner.owner_id,
"group",
"group-other",
user_content="other chat question",
assistant_content="other chat answer",
provider_name="direct_llm",
)
conversations.record_turn(
other.owner_id,
"group",
"group-current",
user_content="other user question",
assistant_content="other user answer",
provider_name="direct_llm",
)
result = FeishuCommandService(db).handle_text(
"重置对话",
principal=owner,
auto_reply=False,
)
assert result["command"] == FeishuCommandName.CONVERSATION_RESET
assert conversations.history(owner.owner_id, "group", "group-current") == []
assert conversations.history(owner.owner_id, "group", "group-other")
assert conversations.history(other.owner_id, "group", "group-current")
def test_ai_and_subscription_commands_are_wired_to_verified_principal(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_personalized(
self: AIService,
owner_id: int,
chat_type: str,
chat_key: str,
prompt: str,
**_: Any,
) -> dict[str, Any]:
captured.update(
{
"owner_id": owner_id,
"chat_type": chat_type,
"chat_key": chat_key,
"prompt": prompt,
}
)
return {
AIResponseKey.OK: True,
AIResponseKey.ANSWER: "账号隔离回答",
AIResponseKey.PROVIDER: "test",
AIResponseKey.RAW: {},
}
monkeypatch.setattr(AIService, "ask_personalized", fake_personalized)
with session_factory() as db:
principal = _principal(
db,
"wiring",
chat_id="verified-private-chat",
)
service = FeishuCommandService(db)
answer = service.handle_text(
"问 你好",
principal=principal,
auto_reply=False,
)
subscription = service.handle_text(
"订阅 每天 09:00给我一个问候",
principal=principal,
auto_reply=False,
)
assert answer["content"] == "账号隔离回答"
assert captured == {
"owner_id": principal.owner_id,
"chat_type": "p2p",
"chat_key": "verified-private-chat",
"prompt": "你好",
}
assert subscription["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
stored = db.scalar(select(PushSubscription))
assert stored is not None
assert stored.owner_id == principal.owner_id
assert stored.target_id == principal.open_id
def test_help_lists_only_role_allowed_company_commands(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = _principal(db, "help-user")
admin = _principal(db, "help-admin", role=FeishuUserRole.ADMIN)
user_help = FeishuCommandService(db).handle_text(
"帮助",
principal=user,
auto_reply=False,
)
admin_help = FeishuCommandService(db).handle_text(
"帮助",
principal=admin,
auto_reply=False,
)
assert "管理员还可以使用" not in user_help["content"]
assert "管理员还可以使用" in admin_help["content"]
def test_my_data_summary_and_two_step_erasure_use_current_identity_only(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = _principal(db, "personal-data")
other = _principal(db, "personal-data-other")
service = FeishuCommandService(db)
service.handle_text(
"学习规则:只属于我的规则",
principal=principal,
auto_reply=False,
)
service.handle_text(
"记住偏好 语言:中文",
principal=principal,
auto_reply=False,
)
service.handle_text(
"关注主题:低空经济",
principal=principal,
auto_reply=False,
)
service.handle_text(
"记住偏好 语气:不应出现在另一用户摘要",
principal=other,
auto_reply=False,
)
service.handle_text(
"订阅 每天 09:00个人提醒",
principal=principal,
auto_reply=False,
)
summary = service.handle_text(
"我的数据",
principal=principal,
auto_reply=False,
)
request = service.handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
confirmation_code = (
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
)
erased = service.handle_text(
f"确认忘记我 {confirmation_code}",
principal=principal,
auto_reply=False,
)
assert summary["command"] == FeishuCommandName.PERSONAL_DATA_SUMMARY
assert "个人规则1 条" in summary["content"]
assert "language=中文" in summary["content"]
assert "低空经济" in summary["content"]
assert "不应出现在另一用户摘要" not in summary["content"]
assert request["command"] == (
FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST
)
assert erased["command"] == (
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM
)
assert db.get(FeishuUser, principal.owner_id) is None
assert db.get(FeishuUser, other.owner_id) is not None
recreated = FeishuIdentityService(db).resolve_or_register(
tenant_key=principal.tenant_key,
open_id=principal.open_id,
)
assert recreated.owner_id != principal.owner_id
assert recreated.role == FeishuUserRole.USER
def test_erasure_confirmation_reply_does_not_create_identifying_send_audit(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: list[dict[str, Any]] = []
def fake_send_text(
_feishu: Any,
chat_id: str | None,
text: str,
actor: str,
*,
record_audit: bool = True,
) -> dict[str, Any]:
captured.append(
{
"chat_id": chat_id,
"text": text,
"actor": actor,
"record_audit": record_audit,
}
)
return {"code": 0}
monkeypatch.setattr(
personal_data_handler,
"send_text_if_configured",
fake_send_text,
)
with session_factory() as db:
principal = _principal(db, "erasure-reply")
service = FeishuCommandService(db)
request = service.handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
confirmation_code = (
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
)
service.handle_text(
f"确认忘记我 {confirmation_code}",
principal=principal,
auto_reply=True,
)
assert len(captured) == 1
assert captured[0]["actor"].startswith("anonymous-")
assert captured[0]["record_audit"] is False
anonymous_logs = list(
db.execute(
select(AuditLog).where(
AuditLog.actor == captured[0]["actor"]
)
).scalars()
)
assert anonymous_logs
assert any(
item.action == PERSONAL_DATA_ERASURE_ACTION
for item in anonymous_logs
)
assert all(
item.action != AuditAction.FEISHU_SEND_TEXT
for item in anonymous_logs
)
assert all(
item.target_type is None
and item.target_id is None
and item.request_payload is None
and item.response_payload is None
and item.request_id is None
for item in anonymous_logs
)
def test_group_chat_does_not_disclose_summary_or_erasure_code(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = _principal(
db,
"group-personal-data",
chat_id="group-personal-data",
chat_type="group",
)
summary = FeishuCommandService(db).handle_text(
"我的数据",
principal=principal,
auto_reply=False,
)
erasure = FeishuCommandService(db).handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
assert "请私聊" in summary["content"]
assert "请私聊" in erasure["content"]
assert "确认码" not in erasure["content"]
assert db.get(FeishuUser, principal.owner_id) is not None