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