feat: 添加飞书用户模块和订阅功能支持

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

60
tests/conftest.py Normal file
View File

@@ -0,0 +1,60 @@
import os
import sys
import tempfile
from pathlib import Path
_database_file = tempfile.NamedTemporaryFile(
prefix="company-ai-platform-tests-",
suffix=".db",
delete=False,
)
_database_file.close()
_database_path = Path(_database_file.name)
# This module is loaded before test modules are imported. Keep every suite run isolated
# from a developer's local .env, database, queues, and external AI/Feishu credentials.
os.environ.update(
{
"COMPANY_AI_DISABLE_DOTENV": "true",
"APP_ENV": "test",
"DATABASE_URL": f"sqlite:///{_database_path.as_posix()}",
"LEGACY_DATABASE_URL": "",
"LEGACY_ALLOWED_QUERIES": "{}",
"LEGACY_PROJECT_QUERY": "",
"LEGACY_TASK_QUERY": "",
"API_KEY": "test-key",
"API_KEYS": "[]",
"AUDIT_API_KEY": "audit-key",
"AUDIT_API_KEYS": "[]",
"AUDIT_API_ACTOR": "audit-manager",
"MODEL_PROVIDER": "noop",
"DIRECT_LLM_API_KEY": "",
"HERMES_API_KEY": "",
"OPENCLAW_API_KEY": "",
"OPENCLAW_GATEWAY_TOKEN": "",
"FEISHU_APP_ID": "",
"FEISHU_APP_SECRET": "",
"FEISHU_ENCRYPT_KEY": "",
"FEISHU_DEFAULT_CHAT_ID": "",
"FEISHU_VERIFICATION_TOKEN": "test-feishu-token",
"FEISHU_ADMIN_IDENTITIES": "",
"FEISHU_USER_FEATURES_ENABLED": "false",
"MARKET_DATA_TOKEN": "",
"SCHEDULER_ENABLED": "false",
"TASK_QUEUE_ENABLED": "false",
"TASK_QUEUE_ALWAYS_EAGER": "false",
"LEGACY_SYNC_ENABLED": "false",
}
)
def pytest_sessionfinish() -> None:
"""Remove the suite database after every test module has finished."""
database_module = sys.modules.get("app.core.database.session")
if database_module is not None:
database_module.engine.dispose()
if database_module.legacy_engine is not None:
database_module.legacy_engine.dispose()
_database_path.unlink(missing_ok=True)

View File

@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.config import Settings
from app.core.database import Base
from app.core.security import OperationsDisabledError
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
@@ -39,13 +38,19 @@ def test_audit_and_outbox_rollback_with_business_transaction() -> None:
assert db.scalar(select(func.count()).select_from(DomainEvent)) == 0
def test_business_service_enforces_read_only_policy(monkeypatch: pytest.MonkeyPatch) -> None:
def test_read_only_mode_allows_local_risk_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with pytest.raises(OperationsDisabledError):
RiskService(Session()).generate_events(actor="pytest")
with Session(engine) as db:
result = RiskService(db).generate_events(actor="pytest")
assert result["created"] == 0
assert db.scalar(select(func.count()).select_from(AuditLog)) == 1
finally:
engine.dispose()
get_settings.cache_clear()

View File

@@ -0,0 +1,101 @@
from datetime import timedelta
from pathlib import Path
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, select, update
from sqlalchemy.orm import sessionmaker
from app.application.events import EventDispatchService
from app.core.utils.time import utc_now
from app.modules.audit.constants import AuditAction
from app.modules.audit.models import AuditLog
from app.modules.events.constants import EventStatus
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
def test_stale_worker_is_fenced_after_expired_lease_is_reclaimed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine(f"sqlite:///{tmp_path / 'event-fencing.db'}")
with engine.begin() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
DomainEvent.__table__.create(engine)
AuditLog.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
with factory() as db:
event = EventService(db).emit(
event_type="test.fencing",
source="pytest",
aggregate_type="test",
aggregate_id="fencing",
idempotency_key="test-event-fencing",
)
event_id = event.event_id
second_result: dict[str, DomainEvent] = {}
with factory() as first_db:
first_worker = EventDispatchService(first_db)
def fail_after_lease_is_reclaimed(record: DomainEvent) -> None:
first_db.add(
AuditLog(
actor="worker-one",
action="stale-worker-side-effect",
target_id=record.event_id,
)
)
with factory() as second_db:
second_db.execute(
update(DomainEvent)
.where(DomainEvent.event_id == record.event_id)
.values(locked_until=utc_now() - timedelta(seconds=1))
)
second_db.commit()
second_worker = EventDispatchService(second_db)
monkeypatch.setattr(
second_worker,
"_handle_event",
lambda claimed: None,
)
second_result["event"] = second_worker.dispatch_event(
record.event_id,
worker_id="worker-two",
)
raise RuntimeError("worker one failed after losing its lease")
monkeypatch.setattr(
first_worker,
"_handle_event",
fail_after_lease_is_reclaimed,
)
with pytest.raises(HTTPException) as exc_info:
first_worker.dispatch_event(event_id, worker_id="worker-one")
assert exc_info.value.status_code == 409
assert second_result["event"].status == EventStatus.PROCESSED
with factory() as db:
stored = db.execute(
select(DomainEvent).where(DomainEvent.event_id == event_id)
).scalar_one()
audit_actions = list(
db.execute(
select(AuditLog.action).order_by(AuditLog.id.asc())
).scalars()
)
assert stored.status == EventStatus.PROCESSED
assert stored.attempts == 2
assert stored.last_error is None
assert stored.locked_by is None
assert stored.locked_until is None
assert audit_actions == [AuditAction.EVENT_DISPATCH]
assert "stale-worker-side-effect" not in audit_actions
finally:
engine.dispose()

View File

@@ -0,0 +1,269 @@
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

View 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

View File

@@ -0,0 +1,77 @@
import time
from typing import Any
import pytest
from app.core.config import get_settings
from app.modules.feishu.client import FeishuClient
from app.modules.feishu.errors import FeishuAPIError
class _Response:
def __init__(self, payload: dict[str, Any], status_code: int = 200):
self.payload = payload
self.status_code = status_code
def json(self) -> dict[str, Any]:
return self.payload
class _Client:
response = _Response({"code": 0, "data": {"message_id": "om-ok"}})
request: dict[str, Any] | None = None
def __init__(self, **_: Any):
pass
def __enter__(self) -> "_Client":
return self
def __exit__(self, *_: Any) -> None:
return None
def post(self, url: str, **kwargs: Any) -> _Response:
_Client.request = {"url": url, **kwargs}
return self.response
def _configured_client(monkeypatch: pytest.MonkeyPatch) -> FeishuClient:
monkeypatch.setenv("FEISHU_APP_ID", "test-app")
monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret")
get_settings.cache_clear()
client = FeishuClient()
client._tenant_access_token = "tenant-token"
client._token_expires_at = time.time() + 60
monkeypatch.setattr("app.modules.feishu.client.httpx.Client", _Client)
return client
def test_message_uuid_is_forwarded_to_feishu(monkeypatch: pytest.MonkeyPatch) -> None:
client = _configured_client(monkeypatch)
try:
result = client.send_text(
"hello",
receive_id="ou-user",
receive_id_type="open_id",
uuid="stable-delivery-uuid",
)
assert result["code"] == 0
assert _Client.request is not None
assert _Client.request["json"]["uuid"] == "stable-delivery-uuid"
finally:
get_settings.cache_clear()
def test_nonzero_feishu_business_code_is_not_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = _configured_client(monkeypatch)
_Client.response = _Response({"code": 99991400, "msg": "rate limited"})
try:
with pytest.raises(FeishuAPIError) as exc_info:
client.send_text("hello", receive_id="ou-user", receive_id_type="open_id")
assert exc_info.value.provider_code == 99991400
assert exc_info.value.retryable is True
finally:
_Client.response = _Response({"code": 0, "data": {"message_id": "om-ok"}})
get_settings.cache_clear()

View File

@@ -0,0 +1,267 @@
import sys
from types import ModuleType
from typing import Any
import pytest
from fastapi import HTTPException
from app.core.config import get_settings
from app.modules.feishu.client import FeishuClient
from app.modules.feishu.errors import FeishuAPIError
class _Response:
def __init__(self, payload: Any, status_code: int = 200):
self.payload = payload
self.status_code = status_code
def json(self) -> Any:
if isinstance(self.payload, Exception):
raise self.payload
return self.payload
class _HTTPClient:
responses: list[_Response] = []
requests: list[dict[str, Any]] = []
def __init__(self, *, timeout: int):
self.timeout = timeout
def __enter__(self) -> "_HTTPClient":
return self
def __exit__(self, *_: Any) -> None:
return None
def post(self, url: str, **kwargs: Any) -> _Response:
self.requests.append({"url": url, "timeout": self.timeout, **kwargs})
return self.responses.pop(0)
@pytest.fixture(autouse=True)
def _reset_settings_and_http(monkeypatch: pytest.MonkeyPatch) -> None:
_HTTPClient.responses = []
_HTTPClient.requests = []
monkeypatch.setattr("app.modules.feishu.client.httpx.Client", _HTTPClient)
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _configure(
monkeypatch: pytest.MonkeyPatch,
*,
app_type: str,
app_ticket: str = "",
) -> None:
monkeypatch.setenv("FEISHU_BASE_URL", "https://open.feishu.test/open-apis")
monkeypatch.setenv("FEISHU_APP_ID", "cli-test")
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret-value")
monkeypatch.setenv("FEISHU_APP_TYPE", app_type)
monkeypatch.setenv("FEISHU_APP_TICKET", app_ticket)
monkeypatch.delenv("FEISHU_DEFAULT_TENANT_KEY", raising=False)
get_settings.cache_clear()
def test_self_app_uses_internal_tenant_token_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="self")
_HTTPClient.responses = [
_Response(
{
"code": 0,
"tenant_access_token": "self-tenant-token",
"expire": 7200,
}
)
]
token = FeishuClient()._get_tenant_access_token("ignored-tenant")
assert token == "self-tenant-token"
assert len(_HTTPClient.requests) == 1
request = _HTTPClient.requests[0]
assert request["url"].endswith("/auth/v3/tenant_access_token/internal")
assert request["json"] == {
"app_id": "cli-test",
"app_secret": "app-secret-value",
}
def test_store_app_caches_app_token_and_isolates_tenant_tokens(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
_HTTPClient.responses = [
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
_Response(
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
),
_Response(
{"code": 0, "tenant_access_token": "tenant-b-token", "expire": 7200}
),
]
client = FeishuClient()
tenant_a = client._get_tenant_access_token("tenant-a")
tenant_b = client._get_tenant_access_token("tenant-b")
tenant_a_again = client._get_tenant_access_token("tenant-a")
assert (tenant_a, tenant_b, tenant_a_again) == (
"tenant-a-token",
"tenant-b-token",
"tenant-a-token",
)
assert len(_HTTPClient.requests) == 3
app_request, tenant_a_request, tenant_b_request = _HTTPClient.requests
assert app_request["url"].endswith("/auth/v3/app_access_token")
assert app_request["json"] == {
"app_id": "cli-test",
"app_secret": "app-secret-value",
"app_ticket": "latest-ticket",
}
assert tenant_a_request["url"].endswith("/auth/v3/tenant_access_token")
assert tenant_a_request["json"] == {
"app_access_token": "app-token",
"tenant_key": "tenant-a",
}
assert tenant_b_request["json"] == {
"app_access_token": "app-token",
"tenant_key": "tenant-b",
}
def test_store_app_prefers_persisted_ticket(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="environment-ticket")
class _TicketService:
def __init__(self, db: object):
assert db is database
def get_ticket(self, app_id: str) -> str:
assert app_id == "cli-test"
return "persisted-ticket"
database = object()
ticket_module = ModuleType("app.modules.feishu.app_tickets")
ticket_module.FeishuAppTicketService = _TicketService
monkeypatch.setitem(sys.modules, ticket_module.__name__, ticket_module)
_HTTPClient.responses = [
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
_Response(
{"code": 0, "tenant_access_token": "tenant-token", "expire": 7200}
),
]
FeishuClient(database)._get_tenant_access_token("tenant-a")
assert _HTTPClient.requests[0]["json"]["app_ticket"] == "persisted-ticket"
def test_store_app_requires_tenant_key_before_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
with pytest.raises(HTTPException) as exc_info:
FeishuClient()._get_tenant_access_token()
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "tenant_key is required for Feishu store apps"
assert _HTTPClient.requests == []
def test_store_app_requires_ticket_before_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="store")
with pytest.raises(HTTPException) as exc_info:
FeishuClient()._get_tenant_access_token("tenant-a")
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Feishu store app ticket is not available"
assert _HTTPClient.requests == []
@pytest.mark.parametrize(
("status_code", "retryable"),
[(401, False), (429, True), (500, True)],
)
def test_token_http_errors_are_classified_without_exposing_secrets(
monkeypatch: pytest.MonkeyPatch,
status_code: int,
retryable: bool,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="secret-ticket-value")
_HTTPClient.responses = [
_Response(
{
"code": 1,
"app_ticket": "secret-ticket-value",
"tenant_access_token": "secret-token-value",
},
status_code=status_code,
)
]
with pytest.raises(FeishuAPIError) as exc_info:
FeishuClient()._get_tenant_access_token("tenant-a")
error = exc_info.value
assert error.retryable is retryable
serialized = f"{error.detail} {error.provider_response}"
assert "secret-ticket-value" not in serialized
assert "secret-token-value" not in serialized
@pytest.mark.parametrize(
("payload", "retryable"),
[
({"code": 99991400, "msg": "rate limited"}, True),
({"code": 10003, "msg": "invalid app credentials"}, False),
],
)
def test_token_business_errors_are_classified(
monkeypatch: pytest.MonkeyPatch,
payload: dict[str, Any],
retryable: bool,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
_HTTPClient.responses = [_Response(payload)]
with pytest.raises(FeishuAPIError) as exc_info:
FeishuClient()._get_tenant_access_token("tenant-a")
assert exc_info.value.provider_code == payload["code"]
assert exc_info.value.retryable is retryable
def test_send_text_uses_the_requested_tenant_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
_HTTPClient.responses = [
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
_Response(
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
),
_Response({"code": 0, "data": {"message_id": "om-message"}}),
]
result = FeishuClient().send_text(
"hello",
receive_id="ou-user",
receive_id_type="open_id",
uuid="stable-uuid",
tenant_key="tenant-a",
)
assert result["code"] == 0
message_request = _HTTPClient.requests[2]
assert message_request["headers"]["Authorization"] == "Bearer tenant-a-token"
assert message_request["json"]["uuid"] == "stable-uuid"

View File

@@ -0,0 +1,486 @@
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

View File

@@ -0,0 +1,193 @@
from datetime import datetime
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from app.core.config import get_settings
def test_personalization_migration_preserves_and_classifies_legacy_data(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
database_path = tmp_path / "personalization-migration.db"
database_url = f"sqlite:///{database_path.as_posix()}"
monkeypatch.setenv("DATABASE_URL", database_url)
get_settings.cache_clear()
config = Config("alembic.ini")
engine = create_engine(database_url)
timestamp = datetime(2026, 7, 26, 8, 0)
try:
command.upgrade(config, "202607260002")
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO ai_memory_entries (
code,
fingerprint,
scope,
subject,
content,
source,
importance,
status,
actor,
created_at,
updated_at
)
VALUES (
:code,
:fingerprint,
'global',
:subject,
:content,
:source,
1,
'active',
'legacy-user',
:created_at,
:updated_at
)
"""
),
[
{
"code": "legacy-rule",
"fingerprint": "legacy-rule-fingerprint",
"subject": "rule",
"content": "Use concise Chinese.",
"source": "user_rule",
"created_at": timestamp,
"updated_at": timestamp,
},
{
"code": "legacy-auto-memory",
"fingerprint": "legacy-auto-fingerprint",
"subject": "preference",
"content": "Prefers market summaries.",
"source": "auto",
"created_at": timestamp,
"updated_at": timestamp,
},
{
"code": "legacy-hermes-memory",
"fingerprint": "legacy-hermes-fingerprint",
"subject": "preference",
"content": "Prefers short answers.",
"source": "hermes",
"created_at": timestamp,
"updated_at": timestamp,
},
],
)
connection.execute(
text(
"""
INSERT INTO market_watchlists (
actor,
symbol,
enabled,
created_at,
updated_at
)
VALUES (
'legacy-user',
'600000.SH',
1,
:created_at,
:updated_at
)
"""
),
{"created_at": timestamp, "updated_at": timestamp},
)
command.upgrade(config, "202607260005")
inspector = inspect(engine)
expected_tables = {
"feishu_app_tickets",
"feishu_admin_bootstrap_tombstones",
"feishu_users",
"user_preferences",
"ai_conversations",
"ai_conversation_messages",
"personal_data_erasure_requests",
"push_subscriptions",
"push_deliveries",
}
assert expected_tables <= set(inspector.get_table_names())
tombstone_columns = {
column["name"]
for column in inspector.get_columns(
"feishu_admin_bootstrap_tombstones"
)
}
assert tombstone_columns == {"id", "identity_hash", "created_at"}
memory_columns = {
column["name"] for column in inspector.get_columns("ai_memory_entries")
}
assert {"owner_id", "kind"} <= memory_columns
memory_indexes = {
index["name"]: index for index in inspector.get_indexes("ai_memory_entries")
}
assert memory_indexes["ix_ai_memory_entries_fingerprint"]["unique"] == 0
assert {
"ix_ai_memory_entries_kind",
"ix_ai_memory_entries_owner_id",
} <= set(memory_indexes)
memory_constraints = {
constraint["name"]
for constraint in inspector.get_unique_constraints("ai_memory_entries")
}
assert "uq_ai_memory_owner_fingerprint" in memory_constraints
memory_foreign_keys = {
foreign_key["name"]
for foreign_key in inspector.get_foreign_keys("ai_memory_entries")
}
assert "fk_ai_memory_entries_owner_id" in memory_foreign_keys
with engine.connect() as connection:
memories = {
row.code: row
for row in connection.execute(
text(
"""
SELECT code, owner_id, kind, source, status
FROM ai_memory_entries
ORDER BY code
"""
)
)
}
watchlist_owner_id = connection.scalar(
text(
"""
SELECT owner_id
FROM market_watchlists
WHERE actor = 'legacy-user' AND symbol = '600000.SH'
"""
)
)
legacy_rule = memories["legacy-rule"]
assert legacy_rule.owner_id is None
assert legacy_rule.kind == "company_rule"
assert legacy_rule.source == "legacy_company"
assert legacy_rule.status == "active"
for code in ("legacy-auto-memory", "legacy-hermes-memory"):
assert memories[code].owner_id is None
assert memories[code].kind == "memory"
assert memories[code].status == "archived"
assert watchlist_owner_id is None
command.check(config)
finally:
engine.dispose()
get_settings.cache_clear()

View File

@@ -0,0 +1,322 @@
from datetime import datetime
from uuid import uuid4
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.application.feishu.handlers.subscriptions import handle_subscription_command
from app.core.database import Base
from app.modules.feishu.constants import FeishuCommandName
from app.modules.feishu.service import FeishuService
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.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
MAX_ACTIVE_SUBSCRIPTIONS,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
def _user(
db: Session,
*,
suffix: str,
role: str = FeishuUserRole.USER,
) -> FeishuUser:
record = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=role,
timezone="Asia/Shanghai",
)
db.add(record)
db.commit()
db.refresh(record)
return record
def _handle(
db: Session,
text: str,
principal: FeishuPrincipal,
) -> dict:
result = handle_subscription_command(
db,
FeishuService(db),
text,
principal,
False,
)
assert result is not None
return result
def test_create_private_subscription_replies_with_normalized_plan() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="private-command")
principal = FeishuPrincipal.from_user(
user,
chat_id="private-chat",
chat_type="p2p",
)
result = _handle(
db,
"订阅 每天 09:00请提醒我喝水",
principal,
)
subscription = db.scalar(select(PushSubscription))
assert subscription is not None
assert result["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
assert "订阅已启用" in result["content"]
assert "计划:每天 09:00" in result["content"]
assert "下次执行:" in result["content"]
assert f"暂停订阅 {subscription.code}" in result["content"]
assert subscription.owner_id == user.id
assert subscription.target_type == SubscriptionTargetType.USER
assert subscription.target_id == user.open_id
assert subscription.prompt == "请提醒我:喝水"
finally:
engine.dispose()
def test_group_subscription_uses_current_verified_chat_and_requires_admin() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
ordinary = _user(db, suffix="ordinary-group")
denied = _handle(
db,
"订阅 每周一 09:00群提醒",
FeishuPrincipal.from_user(
ordinary,
chat_id="verified-group",
chat_type="group",
),
)
assert "无权" in denied["content"]
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
admin = _user(db, suffix="admin-group", role=FeishuUserRole.ADMIN)
accepted = _handle(
db,
"订阅 每周一 09:00群提醒",
FeishuPrincipal.from_user(
admin,
chat_id="verified-group",
chat_type="group",
),
)
subscription = db.scalar(select(PushSubscription))
assert accepted["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
assert subscription is not None
assert subscription.target_type == SubscriptionTargetType.CHAT
assert subscription.target_id == "verified-group"
finally:
engine.dispose()
def test_list_pause_resume_and_cancel_subscription_commands() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="lifecycle-command")
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
_handle(db, "订阅 每天 10:00生命周期测试", principal)
subscription = db.scalar(select(PushSubscription))
assert subscription is not None
listed = _handle(db, "我的订阅", principal)
assert listed["command"] == FeishuCommandName.SUBSCRIPTION_LIST
assert subscription.code in listed["content"]
assert "每天 10:00" in listed["content"]
paused = _handle(db, f"暂停订阅 {subscription.code}", principal)
db.refresh(subscription)
assert paused["command"] == FeishuCommandName.SUBSCRIPTION_PAUSE
assert subscription.status == PushSubscriptionStatus.PAUSED
resumed = _handle(db, f"恢复订阅 {subscription.code}", principal)
db.refresh(subscription)
assert resumed["command"] == FeishuCommandName.SUBSCRIPTION_RESUME
assert subscription.status == PushSubscriptionStatus.ACTIVE
assert "下次执行:" in resumed["content"]
cancelled = _handle(db, f"退订 {subscription.code}", principal)
db.refresh(subscription)
assert cancelled["command"] == FeishuCommandName.SUBSCRIPTION_CANCEL
assert subscription.status == PushSubscriptionStatus.CANCELLED
assert subscription.next_run_at is None
finally:
engine.dispose()
def test_timezone_and_quiet_hour_commands_update_current_user() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="settings-command")
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
timezone_result = _handle(db, "设置时区 Asia/Tokyo", principal)
db.refresh(user)
assert timezone_result["command"] == FeishuCommandName.SUBSCRIPTION_TIMEZONE
assert user.timezone == "Asia/Tokyo"
quiet_result = _handle(db, "设置安静时段 22:00-07:00", principal)
db.refresh(user)
assert quiet_result["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
assert user.quiet_hours_start.strftime("%H:%M") == "22:00"
assert user.quiet_hours_end.strftime("%H:%M") == "07:00"
closed = _handle(db, "关闭安静时段", principal)
db.refresh(user)
assert closed["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
assert user.quiet_hours_start is None
assert user.quiet_hours_end is None
finally:
engine.dispose()
def test_invalid_schedule_and_capacity_error_create_no_partial_record() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="invalid-command")
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
invalid = _handle(
db,
"订阅 每隔10分钟过于频繁",
principal,
)
assert "示例" not in invalid["content"]
assert "订阅 每天 09:00" in invalid["content"]
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
for index in range(MAX_ACTIVE_SUBSCRIPTIONS):
db.add(
PushSubscription(
code=f"SUB-CAPACITY-{index}",
owner_id=user.id,
target_type=SubscriptionTargetType.USER,
target_id=user.open_id,
prompt=f"已有订阅 {index}",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone=user.timezone,
next_run_at=datetime(2026, 7, 27, 1, 0),
status=PushSubscriptionStatus.ACTIVE,
consented_at=datetime(2026, 7, 26, 0, 0),
)
)
db.commit()
limited = _handle(
db,
"订阅 每天 11:00第 51 条",
principal,
)
assert "最多 50 个" in limited["content"]
assert (
db.scalar(select(func.count()).select_from(PushSubscription))
== MAX_ACTIVE_SUBSCRIPTIONS
)
finally:
engine.dispose()
def test_unrelated_text_is_not_claimed_by_subscription_handler() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="unrelated-command")
assert (
handle_subscription_command(
db,
FeishuService(db),
"今天怎么样",
FeishuPrincipal.from_user(user),
False,
)
is None
)
finally:
engine.dispose()
def test_list_explains_daily_delivery_limit_skip() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="delivery-note")
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
_handle(db, "订阅 每天 10:00投递说明", principal)
subscription = db.scalar(select(PushSubscription))
assert subscription is not None
now = datetime(2026, 7, 26, 1, 0)
db.add(
PushDelivery(
code="DEL-DAILY-LIMIT",
subscription_id=subscription.id,
scheduled_for=now,
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=PushDeliveryStatus.SKIPPED,
next_attempt_at=None,
last_error=DAILY_DELIVERY_LIMIT_REACHED,
created_at=now,
updated_at=now,
)
)
db.commit()
listed = _handle(db, "我的订阅", principal)
assert "每日最多 96 条限制" in listed["content"]
finally:
engine.dispose()
@pytest.mark.parametrize(
"command",
[
"设置时区 Invalid/Timezone",
"设置安静时段 25:00-07:00",
"订阅 每隔999999999999999999小时不会创建",
],
)
def test_invalid_settings_and_oversized_interval_return_command_error(
command: str,
) -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix=f"invalid-{abs(hash(command))}")
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
result = _handle(db, command, principal)
assert "未执行" in result["content"] or "无法识别" in result["content"]
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
finally:
engine.dispose()

View File

@@ -0,0 +1,327 @@
import json
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import Base
from app.modules.feishu.app_tickets import FeishuAppTicketService
from app.modules.feishu_users.models import FeishuUser
from app.modules.observability.constants import (
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.service import ObservabilityService
from app.modules.subscriptions.constants import (
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
@pytest.fixture(autouse=True)
def _reset_settings() -> None:
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _add_active_subscription(
db: Session,
*,
tenant_key: str,
suffix: str,
) -> PushSubscription:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=tenant_key,
open_id=f"open-{suffix}",
)
db.add(owner)
db.flush()
subscription = PushSubscription(
code=f"SUB-{suffix}",
owner_id=owner.id,
target_type=SubscriptionTargetType.USER,
target_id=owner.open_id,
prompt="发送个人提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone="Asia/Shanghai",
next_run_at=datetime(2026, 7, 27, 1, 0),
status=PushSubscriptionStatus.ACTIVE,
consented_at=datetime(2026, 7, 26, 1, 0),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
def _add_delivery(
db: Session,
subscription: PushSubscription,
*,
suffix: str,
status_value: str,
scheduled_for: datetime,
) -> None:
future = datetime(2030, 1, 1, 0, 0)
db.add(
PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=scheduled_for,
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=status_value,
next_attempt_at=(
future
if status_value
in {
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
}
else None
),
locked_by=(
"readiness-worker"
if status_value == PushDeliveryStatus.PROCESSING
else None
),
locked_until=(
future
if status_value == PushDeliveryStatus.PROCESSING
else None
),
)
)
db.commit()
def test_subscription_readiness_requires_basic_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="missing")
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["credentials_configured"] is False
assert result["reasons"] == ["credentials_missing"]
finally:
engine.dispose()
def test_self_app_readiness_rejects_multiple_active_tenants(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="self-a")
single = ObservabilityService(db)._feishu_subscriptions_check()
assert single[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert single["active_tenant_count"] == 1
_add_active_subscription(db, tenant_key="tenant-b", suffix="self-b")
multiple = ObservabilityService(db)._feishu_subscriptions_check()
assert multiple[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert multiple["active_tenant_count"] == 2
assert multiple["reasons"] == ["self_app_multiple_tenants"]
finally:
engine.dispose()
def test_store_app_readiness_accepts_persisted_ticket_without_exposing_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ticket = "readiness-ticket-secret"
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
monkeypatch.setenv("FEISHU_APP_TICKET", "")
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-a")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="store")
missing = ObservabilityService(db)._feishu_subscriptions_check()
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert missing["reasons"] == ["app_ticket_missing"]
FeishuAppTicketService(db).store_verified("cli-store", ticket)
configured = ObservabilityService(db)._feishu_subscriptions_check()
assert configured[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert configured["ticket_configured"] is True
assert configured["default_tenant_configured"] is True
assert configured["reasons"] == []
assert ticket not in json.dumps(configured)
finally:
engine.dispose()
def test_store_app_readiness_requires_default_tenant(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
monkeypatch.setenv("FEISHU_APP_TICKET", "environment-ticket")
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
_add_active_subscription(db, tenant_key="tenant-a", suffix="default")
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["ticket_configured"] is True
assert result["default_tenant_configured"] is False
assert result["reasons"] == ["default_tenant_missing"]
finally:
engine.dispose()
def test_readiness_checks_credentials_for_processable_deliveries_without_active_plan(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
subscription = _add_active_subscription(
db,
tenant_key="tenant-a",
suffix="durable",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, status_value in enumerate(
[
PushDeliveryStatus.PENDING,
PushDeliveryStatus.RETRY,
PushDeliveryStatus.PROCESSING,
]
):
_add_delivery(
db,
subscription,
suffix=f"durable-{index}",
status_value=status_value,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["active"] == 0
assert result["processable_deliveries"] == 3
assert result["active_tenant_count"] == 1
assert result["reasons"] == ["credentials_missing"]
finally:
engine.dispose()
def test_readiness_ignores_terminal_deliveries_without_active_plan() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
subscription = _add_active_subscription(
db,
tenant_key="tenant-a",
suffix="terminal",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, status_value in enumerate(
[
PushDeliveryStatus.SENT,
PushDeliveryStatus.FAILED,
PushDeliveryStatus.SKIPPED,
]
):
_add_delivery(
db,
subscription,
suffix=f"terminal-{index}",
status_value=status_value,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result == {
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
"active": 0,
"processable_deliveries": 0,
}
finally:
engine.dispose()
def test_self_app_counts_tenants_from_processable_deliveries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
scheduled_for = datetime(2026, 7, 27, 1, 0)
for index, tenant_key in enumerate(["tenant-a", "tenant-b"]):
subscription = _add_active_subscription(
db,
tenant_key=tenant_key,
suffix=f"delivery-tenant-{index}",
)
subscription.status = PushSubscriptionStatus.COMPLETED
subscription.next_run_at = None
db.commit()
_add_delivery(
db,
subscription,
suffix=f"delivery-tenant-{index}",
status_value=PushDeliveryStatus.PENDING,
scheduled_for=scheduled_for + timedelta(minutes=index),
)
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert result["active"] == 0
assert result["processable_deliveries"] == 2
assert result["active_tenant_count"] == 2
assert result["reasons"] == ["self_app_multiple_tenants"]
finally:
engine.dispose()

View File

@@ -0,0 +1,157 @@
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.application.feishu.commands import FeishuCommandService
from app.core.config import get_settings
from app.core.database import Base
from app.modules.audit.models import AuditLog
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
from app.modules.feishu_users.principal import FeishuPrincipal
class RecordingFeishuClient:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def send_text(
self,
text: str,
receive_id: str | None,
receive_id_type: str,
uuid: str | None,
tenant_key: str | None = None,
) -> dict[str, Any]:
self.calls.append(
{
"operation": "text",
"receive_id": receive_id,
"tenant_key": tenant_key,
"uuid": uuid,
}
)
return {"code": 0, "data": {"message_id": "om-text"}}
def send_card(
self,
card: dict[str, Any],
receive_id: str | None,
receive_id_type: str,
uuid: str | None,
tenant_key: str | None = None,
) -> dict[str, Any]:
self.calls.append(
{
"operation": "card",
"receive_id": receive_id,
"tenant_key": tenant_key,
"uuid": uuid,
}
)
return {"code": 0, "data": {"message_id": "om-card"}}
def upload_image(
self,
image: bytes,
filename: str = "lifecycle-report.png",
tenant_key: str | None = None,
) -> dict[str, Any]:
self.calls.append(
{
"operation": "image",
"filename": filename,
"tenant_key": tenant_key,
}
)
return {"code": 0, "data": {"image_key": "img-key"}}
@pytest.fixture
def database(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Session]:
monkeypatch.setenv("FEISHU_APP_ID", "cli-routing")
monkeypatch.setenv("FEISHU_APP_SECRET", "routing-secret")
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-default")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine, tables=[AuditLog.__table__])
try:
with Session(engine) as db:
yield db
finally:
engine.dispose()
get_settings.cache_clear()
def test_service_routes_text_card_and_image_through_selected_tenant(
database: Session,
) -> None:
service = FeishuService(database)
client = RecordingFeishuClient()
service.client = client
service.send_text("默认消息", receive_id="oc-default")
service.set_tenant_key("tenant-a")
service.send_card({"elements": []}, receive_id="oc-a")
service.upload_image(b"png")
service.send_text(
"显式覆盖",
receive_id="oc-b",
tenant_key="tenant-b",
)
assert [
(item["operation"], item["tenant_key"])
for item in client.calls
] == [
("text", "tenant-default"),
("card", "tenant-a"),
("image", "tenant-a"),
("text", "tenant-b"),
]
def test_command_reply_uses_verified_principal_tenant(
database: Session,
) -> None:
commands = FeishuCommandService(database)
client = RecordingFeishuClient()
commands.feishu.client = client
principal = FeishuPrincipal(
owner_id=1,
user_code="FSU-routing",
tenant_key="tenant-principal",
open_id="ou-routing",
union_id=None,
feishu_user_id=None,
role=FeishuUserRole.USER,
status=FeishuUserStatus.ACTIVE,
timezone="Asia/Shanghai",
quiet_hours_start=None,
quiet_hours_end=None,
chat_id="oc-routing",
chat_type="p2p",
)
result = commands.handle_text(
"帮助",
auto_reply=True,
principal=principal,
)
assert result["command"] == "help"
assert commands.feishu.tenant_key == "tenant-principal"
assert client.calls == [
{
"operation": "text",
"receive_id": "oc-routing",
"tenant_key": "tenant-principal",
"uuid": None,
}
]

418
tests/test_feishu_users.py Normal file
View File

@@ -0,0 +1,418 @@
from collections.abc import Iterator
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.core.config import get_settings
from app.core.database import Base, get_db
from app.modules.audit.models import AuditLog
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.constants import (
FeishuCapability,
FeishuUserAuditAction,
FeishuUserRole,
FeishuUserStatus,
parse_admin_identities,
)
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
)
from app.modules.feishu_users.routes import router
from app.modules.feishu_users.services import (
FeishuIdentityService,
FeishuUserManagementService,
)
@pytest.fixture
def session_factory() -> Iterator[sessionmaker[Session]]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(
engine,
tables=[
AuditLog.__table__,
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
MarketWatchlist.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
Base.metadata.drop_all(
engine,
tables=[
MarketWatchlist.__table__,
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
AuditLog.__table__,
],
)
engine.dispose()
def test_identity_registration_is_tenant_scoped_and_bootstraps_admin(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
service = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
admin = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
union_id="on-admin",
user_id="u-admin",
)
ordinary = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
other_tenant = service.resolve_or_register(
tenant_key="tenant-b",
open_id="ou-user",
)
assert admin.role == FeishuUserRole.ADMIN
assert admin.is_admin is True
assert admin.has_capability(FeishuCapability.USER_ADMINISTRATION) is True
assert ordinary.role == FeishuUserRole.USER
assert ordinary.has_capability(FeishuCapability.PERSONAL_AI) is True
assert ordinary.has_capability(FeishuCapability.COMPANY_REPORTS) is False
assert ordinary.owner_id != other_tenant.owner_id
assert db.scalar(select(FeishuUser).where(FeishuUser.id == admin.owner_id)).union_id == (
"on-admin"
)
registrations = list(
db.execute(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.REGISTER
)
).scalars()
)
assert len(registrations) == 3
assert all("ou-" not in (item.request_payload or "") for item in registrations)
def test_each_configured_admin_identity_can_bootstrap_once(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
service = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin-a,tenant-b:ou-admin-b",
)
first = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin-a",
)
second = service.resolve_or_register(
tenant_key="tenant-b",
open_id="ou-admin-b",
)
assert first.role == FeishuUserRole.ADMIN
assert second.role == FeishuUserRole.ADMIN
def test_identity_refreshes_existing_sender_without_reapplying_admin_bootstrap(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-existing",
)
refreshed = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-existing",
).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-existing",
union_id="on-existing",
)
assert refreshed.owner_id == principal.owner_id
assert refreshed.role == FeishuUserRole.USER
assert refreshed.union_id == "on-existing"
assert db.scalar(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.AUTHENTICATE
)
)
def test_first_registration_claims_only_matching_legacy_watchlist(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
db.add_all(
[
MarketWatchlist(
actor="ou-claim",
symbol="600000.SH",
enabled=True,
),
MarketWatchlist(
actor="ou-other",
symbol="000001.SZ",
enabled=True,
),
]
)
db.commit()
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-claim",
open_id="ou-claim",
)
records = list(
db.execute(
select(MarketWatchlist).order_by(MarketWatchlist.id.asc())
).scalars()
)
assert records[0].owner_id == principal.owner_id
assert records[1].owner_id is None
def test_identity_rejects_missing_parts_and_invalid_admin_config(
session_factory: sessionmaker[Session],
) -> None:
assert parse_admin_identities("tenant-a:ou-a, tenant-b:ou-b") == {
("tenant-a", "ou-a"),
("tenant-b", "ou-b"),
}
with pytest.raises(ValueError):
parse_admin_identities("missing-separator")
with session_factory() as db:
with pytest.raises(HTTPException) as exc_info:
FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id=" ",
)
assert exc_info.value.status_code == 422
assert db.scalar(select(FeishuUser)) is None
def test_management_protects_last_active_admin_and_audits_denial(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
identity = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
admin = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
)
user = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
management = FeishuUserManagementService(db)
with pytest.raises(HTTPException) as demote_error:
management.update_user(
admin.user_code,
changes={"role": FeishuUserRole.USER},
actor="service-admin",
)
assert demote_error.value.status_code == 409
with pytest.raises(HTTPException) as disable_error:
management.update_user(
admin.user_code,
changes={"status": FeishuUserStatus.DISABLED},
actor="service-admin",
)
assert disable_error.value.status_code == 409
promoted = management.update_user(
user.user_code,
changes={"role": FeishuUserRole.ADMIN},
actor="service-admin",
)
assert promoted.role == FeishuUserRole.ADMIN
demoted = management.update_user(
admin.user_code,
changes={"role": FeishuUserRole.USER},
actor="service-admin",
)
assert demoted.role == FeishuUserRole.USER
denied = list(
db.execute(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.UPDATE_DENIED
)
).scalars()
)
assert len(denied) == 2
assert all(item.actor == "service-admin" for item in denied)
def test_management_validates_timezone_and_quiet_hours(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
management = FeishuUserManagementService(db)
updated = management.update_user(
user.user_code,
changes={
"timezone": "Europe/London",
"quiet_hours_start": "22:30",
"quiet_hours_end": "07:15",
},
actor="service-admin",
)
assert updated.timezone == "Europe/London"
assert updated.quiet_hours_start.isoformat() == "22:30:00"
assert updated.quiet_hours_end.isoformat() == "07:15:00"
with pytest.raises(HTTPException) as timezone_error:
management.update_user(
user.user_code,
changes={"timezone": "Invalid/Nowhere"},
actor="service-admin",
)
assert timezone_error.value.status_code == 422
with pytest.raises(HTTPException) as quiet_error:
management.update_user(
user.user_code,
changes={"quiet_hours_end": None},
actor="service-admin",
)
assert quiet_error.value.status_code == 422
cleared = management.update_user(
user.user_code,
changes={
"quiet_hours_start": None,
"quiet_hours_end": None,
},
actor="service-admin",
)
assert cleared.quiet_hours_start is None
assert cleared.quiet_hours_end is None
def test_disabled_principal_has_no_capabilities(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
FeishuUserManagementService(db).update_user(
principal.user_code,
changes={"status": FeishuUserStatus.DISABLED},
actor="service-admin",
)
refreshed = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
assert refreshed.is_active is False
assert refreshed.has_capability(FeishuCapability.PERSONAL_AI) is False
with pytest.raises(HTTPException) as exc_info:
refreshed.require_capability(FeishuCapability.PERSONAL_AI)
assert exc_info.value.status_code == 403
def test_internal_user_routes_use_api_principal_and_ignore_body_identity(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_KEY", "service-key")
monkeypatch.setenv("API_ACTOR", "service-admin")
monkeypatch.setenv("API_KEYS", "[]")
get_settings.cache_clear()
with session_factory() as db:
identity = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
)
user = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
user_code = user.user_code
app = FastAPI()
app.include_router(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
client = TestClient(app)
try:
assert (
client.get("/api/v1/integrations/feishu/users").status_code
== 401
)
response = client.patch(
f"/api/v1/integrations/feishu/users/{user_code}",
headers={"X-API-Key": "service-key"},
json={
"role": "admin",
"actor": "forged-user",
"open_id": "ou-forged",
},
)
assert response.status_code == 200
assert response.json()["role"] == "admin"
assert response.json()["open_id"] == "ou-user"
listed = client.get(
"/api/v1/integrations/feishu/users?role=admin",
headers={"X-API-Key": "service-key"},
)
assert listed.status_code == 200
assert listed.json()["total"] == 2
detail = client.get(
f"/api/v1/integrations/feishu/users/{user_code}",
headers={"X-API-Key": "service-key"},
)
assert detail.status_code == 200
with session_factory() as db:
update_log = db.execute(
select(AuditLog)
.where(AuditLog.action == FeishuUserAuditAction.UPDATE)
.order_by(AuditLog.id.desc())
).scalars().first()
assert update_log is not None
assert update_log.actor == "service-admin"
assert "forged-user" not in (update_log.request_payload or "")
assert "ou-forged" not in (update_log.request_payload or "")
finally:
get_settings.cache_clear()

View File

@@ -0,0 +1,84 @@
import base64
import json
import time
from hashlib import sha256
import pytest
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
from fastapi import HTTPException
from app.core.config import get_settings
from app.modules.feishu.event_verification import FeishuWebhookVerifier
def _encrypt_event(payload: dict, encrypt_key: str) -> str:
key = sha256(encrypt_key.encode("utf-8")).digest()
padder = PKCS7(algorithms.AES.block_size).padder()
cleartext = json.dumps(payload, ensure_ascii=False).encode("utf-8")
padded = padder.update(cleartext) + padder.finalize()
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
return base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode()
def test_plain_webhook_requires_the_configured_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verification-token")
monkeypatch.delenv("FEISHU_ENCRYPT_KEY", raising=False)
get_settings.cache_clear()
try:
raw = json.dumps(
{"header": {"token": "verification-token"}, "event": {}}
).encode()
assert FeishuWebhookVerifier().verify(raw, {})["event"] == {}
invalid = json.dumps(
{"header": {"token": "wrong-token"}, "event": {}}
).encode()
with pytest.raises(HTTPException) as exc_info:
FeishuWebhookVerifier().verify(invalid, {})
assert exc_info.value.status_code == 401
finally:
get_settings.cache_clear()
def test_encrypted_webhook_requires_valid_signature_and_decrypts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
encrypt_key = "test-encrypt-key"
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verification-token")
monkeypatch.setenv("FEISHU_ENCRYPT_KEY", encrypt_key)
get_settings.cache_clear()
try:
event = {
"header": {
"token": "verification-token",
"tenant_key": "tenant-a",
},
"event": {"sender": {"sender_id": {"open_id": "ou-a"}}},
}
raw = json.dumps(
{"encrypt": _encrypt_event(event, encrypt_key)},
separators=(",", ":"),
).encode()
timestamp = str(int(time.time()))
nonce = "nonce"
signature = sha256(
timestamp.encode()
+ nonce.encode()
+ encrypt_key.encode()
+ raw
).hexdigest()
headers = {
"X-Lark-Request-Timestamp": timestamp,
"X-Lark-Request-Nonce": nonce,
"X-Lark-Signature": signature,
}
assert FeishuWebhookVerifier().verify(raw, headers) == event
headers["X-Lark-Signature"] = "invalid"
with pytest.raises(HTTPException) as exc_info:
FeishuWebhookVerifier().verify(raw, headers)
assert exc_info.value.status_code == 401
finally:
get_settings.cache_clear()

View File

@@ -0,0 +1,69 @@
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.core.background.task_queue import lifecycle as lifecycle_queue
from app.core.database import Base
from app.modules.reports.constants import ReportType
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
from app.tasks import celery_app
def test_lifecycle_enqueue_failure_marks_workflow_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
class BrokenSignature:
def apply_async(self) -> None:
raise RuntimeError("broker unavailable")
monkeypatch.setattr(lifecycle_queue, "SessionLocal", factory)
monkeypatch.setattr(
lifecycle_queue,
"get_settings",
lambda: SimpleNamespace(task_queue_enabled=True),
)
monkeypatch.setattr(
celery_app,
"signature",
lambda *args, **kwargs: BrokenSignature(),
)
try:
with pytest.raises(RuntimeError, match="broker unavailable"):
lifecycle_queue.enqueue_lifecycle_report(
report_type=ReportType.WEEKLY,
actor="pytest",
)
with factory() as db:
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type
== WorkflowType.LIFECYCLE_REPORT
)
).scalar_one()
actions = list(
db.execute(
select(WorkflowAction)
.where(WorkflowAction.workflow_code == workflow.code)
.order_by(WorkflowAction.id.asc())
).scalars()
)
assert workflow.status == WorkflowStatus.FAILED
assert workflow.current_step == "enqueue_failed"
assert workflow.completed_at is not None
assert workflow.payload == {"error": "broker unavailable"}
assert [action.action for action in actions] == [
"queued",
"enqueue_failed",
]
assert actions[-1].to_status == WorkflowStatus.FAILED
finally:
engine.dispose()

View File

@@ -0,0 +1,95 @@
from datetime import timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.core.security import ApiPrincipal
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.routes import list_memory
from app.modules.dashboard.routes import dashboard_summary
from app.modules.observability.routes import metrics as observability_metrics
def _session_factory():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine, expire_on_commit=False)
def _seed_expired_memory(factory: sessionmaker, code: str, subject: str) -> None:
with factory() as db:
db.add(
AIMemoryEntry(
code=code,
scope="project",
subject=subject,
content="expired",
status=AIMemoryStatus.ACTIVE,
expires_at=utc_now() - timedelta(seconds=1),
)
)
db.commit()
def _assert_archived(factory: sessionmaker, code: str) -> None:
with factory() as db:
stored = db.execute(
select(AIMemoryEntry).where(AIMemoryEntry.code == code)
).scalar_one()
assert stored.status == AIMemoryStatus.ARCHIVED
def test_memory_list_route_persists_expiration_at_request_boundary() -> None:
engine, factory = _session_factory()
try:
code = "MEM-LIST-EXPIRED"
_seed_expired_memory(factory, code, "P-LIST")
with factory() as db:
result = list_memory(
scope="project",
subject="P-LIST",
status=AIMemoryStatus.ACTIVE,
limit=100,
db=db,
)
assert result[AIMemoryResponseKey.ITEMS] == []
_assert_archived(factory, code)
finally:
engine.dispose()
def test_observability_metrics_route_persists_memory_expiration() -> None:
engine, factory = _session_factory()
try:
code = "MEM-METRICS-EXPIRED"
_seed_expired_memory(factory, code, "P-METRICS")
with factory() as db:
observability_metrics(db=db)
_assert_archived(factory, code)
finally:
engine.dispose()
def test_dashboard_route_excludes_and_archives_expired_memory() -> None:
engine, factory = _session_factory()
try:
code = "MEM-DASHBOARD-EXPIRED"
_seed_expired_memory(factory, code, "P-DASHBOARD")
with factory() as db:
result = dashboard_summary(
db=db,
principal=ApiPrincipal(actor="pytest"),
)
assert result["metrics"]["active_ai_memory"] == 0
_assert_archived(factory, code)
finally:
engine.dispose()

View File

@@ -0,0 +1,469 @@
import json
from collections.abc import Iterator
from datetime import timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.feishu.personal_data import (
PERSONAL_DATA_ERASURE_ACTION,
FeishuPersonalDataService,
)
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.core.utils.time import utc_now
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.models import AuditLog
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
)
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.feishu_users.routes import router
from app.modules.feishu_users.services import FeishuIdentityService
from app.modules.personalization.models import (
AIConversation,
AIConversationMessage,
PersonalDataErasureRequest,
UserPreference,
)
from app.modules.personalization.schemas import ErasureResult
from app.modules.subscriptions.models import PushDelivery, PushSubscription
@pytest.fixture
def session_factory() -> Iterator[sessionmaker[Session]]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(engine, "connect")
def enable_foreign_keys(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
Base.metadata.drop_all(engine)
engine.dispose()
def _create_user(
db: Session,
suffix: str,
*,
role: str = FeishuUserRole.USER,
) -> FeishuUser:
user = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"ou-open-{suffix}",
union_id=f"on-union-{suffix}",
user_id=f"cli-user-{suffix}",
role=role,
timezone="Asia/Shanghai",
)
db.add(user)
db.flush()
return user
def _seed_personal_data(db: Session, user: FeishuUser) -> None:
db.add(
UserPreference(
owner_id=user.id,
category="tone",
value="简洁",
normalized_value="简洁",
source="explicit",
)
)
conversation = AIConversation(
owner_id=user.id,
chat_type="private",
chat_key=user.open_id,
)
db.add(conversation)
db.flush()
db.add(
AIConversationMessage(
conversation_id=conversation.id,
role="user",
content="仅属于我的历史",
)
)
db.add(
AIMemoryEntry(
code=f"MEM-{uuid4().hex}",
owner_id=user.id,
kind="memory",
scope="user",
subject="preference",
content="个人记忆",
source="explicit",
actor=user.open_id,
)
)
db.add(
MarketWatchlist(
owner_id=user.id,
actor=user.open_id,
symbol="600000.SH",
enabled=True,
)
)
subscription = PushSubscription(
code=f"SUB-{uuid4().hex}",
owner_id=user.id,
target_type="user",
target_id=user.open_id,
prompt="个人提醒",
schedule_type="daily",
schedule_config={"hour": 9, "minute": 0},
timezone="Asia/Shanghai",
next_run_at=utc_now() + timedelta(days=1),
status="active",
consented_at=utc_now(),
)
db.add(subscription)
db.flush()
db.add(
PushDelivery(
code=f"DEL-{uuid4().hex}",
subscription_id=subscription.id,
scheduled_for=utc_now(),
idempotency_key=uuid4().hex,
message_uuid=str(uuid4()),
status="pending",
next_attempt_at=utc_now(),
)
)
db.add(
AuditLog(
actor=f"feishu:{user.open_id}",
source="feishu",
action="test.personal.action",
target_type="feishu-user",
target_id=user.code,
request_payload=json.dumps(
{
"open_id": user.open_id,
"union_id": user.union_id,
"private_note": "仅属于该用户的请求内容",
}
),
response_payload=json.dumps(
{
"user_id": user.user_id,
"code": user.code,
"private_answer": "仅属于该用户的响应内容",
}
),
request_id=f"request-{user.code}",
)
)
db.commit()
def test_confirmation_is_one_user_only_and_expires(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
first = _create_user(db, "first")
second = _create_user(db, "second")
db.commit()
first_principal = FeishuPrincipal.from_user(first)
second_principal = FeishuPrincipal.from_user(second)
service = FeishuPersonalDataService(db)
confirmation = service.request_confirmation(first_principal)
with pytest.raises(HTTPException) as wrong_code:
service.confirm(first_principal, "WRONG")
assert wrong_code.value.status_code == 422
with pytest.raises(HTTPException) as cross_user:
service.confirm(second_principal, confirmation.confirmation_code)
assert cross_user.value.status_code == 422
request = db.execute(
select(PersonalDataErasureRequest).where(
PersonalDataErasureRequest.owner_id == first.id
)
).scalar_one()
request.expires_at = utc_now() - timedelta(seconds=1)
db.commit()
with pytest.raises(HTTPException) as expired:
service.confirm(first_principal, confirmation.confirmation_code)
assert expired.value.status_code == 422
assert db.get(FeishuUser, first.id) is not None
assert db.get(FeishuUser, second.id) is not None
def test_confirm_erases_all_personal_data_and_anonymizes_audit(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = _create_user(db, "erase")
other = _create_user(db, "other")
_seed_personal_data(db, user)
_seed_personal_data(db, other)
owner_id = user.id
tenant_key = user.tenant_key
open_id = user.open_id
identifiers = (user.code, user.open_id, user.union_id, user.user_id)
principal = FeishuPrincipal.from_user(user)
service = FeishuPersonalDataService(db)
confirmation = service.request_confirmation(principal)
result = service.confirm(principal, confirmation.confirmation_code)
assert result.deleted["deliveries"] == 1
assert result.deleted["subscriptions"] == 1
assert result.deleted["preferences"] == 1
assert result.deleted["conversation_messages"] == 1
assert result.deleted["conversations"] == 1
assert result.deleted["ai_memory"] == 1
assert result.deleted["watchlist"] == 1
assert result.deleted["identity"] == 1
assert db.get(FeishuUser, owner_id) is None
assert db.get(FeishuUser, other.id) is not None
assert db.scalar(
select(PushSubscription).where(PushSubscription.owner_id == owner_id)
) is None
assert db.scalar(
select(UserPreference).where(UserPreference.owner_id == owner_id)
) is None
assert db.scalar(
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
) is None
assert db.scalar(
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
) is None
assert db.scalar(
select(AIConversation).where(AIConversation.owner_id == owner_id)
) is None
logs = list(db.execute(select(AuditLog)).scalars())
serialized_logs = "\n".join(
" ".join(
str(value or "")
for value in (
log.actor,
log.target_id,
log.request_payload,
log.response_payload,
)
)
for log in logs
)
assert all(identifier not in serialized_logs for identifier in identifiers)
final_log = db.execute(
select(AuditLog).where(AuditLog.action == PERSONAL_DATA_ERASURE_ACTION)
).scalar_one()
assert final_log.actor == result.anonymous_id
assert final_log.target_type is None
assert final_log.target_id is None
assert final_log.request_payload is None
assert final_log.response_payload is None
assert final_log.request_id is None
assert final_log.status == "success"
anonymized_history = db.execute(
select(AuditLog).where(
AuditLog.action == "test.personal.action",
AuditLog.actor == result.anonymous_id,
)
).scalar_one()
assert anonymized_history.target_type is None
assert anonymized_history.target_id is None
assert anonymized_history.request_payload is None
assert anonymized_history.response_payload is None
assert anonymized_history.request_id is None
assert anonymized_history.status == "success"
recreated = FeishuIdentityService(db).resolve_or_register(
tenant_key=tenant_key,
open_id=open_id,
)
assert recreated.owner_id != owner_id
assert recreated.user_code not in identifiers
assert recreated.role == FeishuUserRole.USER
def test_last_active_admin_is_protected_until_another_admin_exists(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
admin = _create_user(db, "admin", role=FeishuUserRole.ADMIN)
db.commit()
principal = FeishuPrincipal.from_user(admin)
service = FeishuPersonalDataService(db)
confirmation = service.request_confirmation(principal)
with pytest.raises(HTTPException) as protected:
service.confirm(principal, confirmation.confirmation_code)
assert protected.value.status_code == 409
assert db.get(FeishuUser, admin.id) is not None
second_admin = _create_user(
db,
"second-admin",
role=FeishuUserRole.ADMIN,
)
db.commit()
result = service.confirm(principal, confirmation.confirmation_code)
assert result.deleted["identity"] == 1
assert db.get(FeishuUser, admin.id) is None
assert db.get(FeishuUser, second_admin.id) is not None
def test_erased_configured_admin_is_not_bootstrapped_again(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"FEISHU_ADMIN_IDENTITIES",
"tenant-bootstrap:ou-bootstrap",
)
get_settings.cache_clear()
try:
with session_factory() as db:
identity = FeishuIdentityService(db)
initial = identity.resolve_or_register(
tenant_key="tenant-bootstrap",
open_id="ou-bootstrap",
)
assert initial.role == FeishuUserRole.ADMIN
_create_user(db, "replacement-admin", role=FeishuUserRole.ADMIN)
db.commit()
service = FeishuPersonalDataService(db)
confirmation = service.request_confirmation(initial)
result = service.confirm(initial, confirmation.confirmation_code)
tombstone = db.scalar(select(FeishuAdminBootstrapTombstone))
assert tombstone is not None
assert len(tombstone.identity_hash) == 64
assert "tenant-bootstrap" not in tombstone.identity_hash
assert "ou-bootstrap" not in tombstone.identity_hash
recreated = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-bootstrap",
open_id="ou-bootstrap",
)
assert recreated.owner_id != initial.owner_id
assert recreated.role == FeishuUserRole.USER
assert result.deleted["identity"] == 1
finally:
get_settings.cache_clear()
def test_extra_hook_failure_rolls_back_the_deletion_transaction(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = _create_user(db, "rollback")
_seed_personal_data(db, user)
owner_id = user.id
principal = FeishuPrincipal.from_user(user)
confirmation = FeishuPersonalDataService(db).request_confirmation(principal)
def fail_hook(_db: Session, _owner_id: int, _anonymous_id: str) -> None:
raise RuntimeError("integration erasure failed")
with pytest.raises(RuntimeError, match="integration erasure failed"):
FeishuPersonalDataService(
db,
extra_hooks=(fail_hook,),
).confirm(principal, confirmation.confirmation_code)
assert db.get(FeishuUser, owner_id) is not None
assert db.scalar(
select(UserPreference).where(UserPreference.owner_id == owner_id)
) is not None
assert db.scalar(
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
) is not None
assert db.scalar(
select(PushSubscription).where(PushSubscription.owner_id == owner_id)
) is not None
assert db.scalar(
select(PersonalDataErasureRequest).where(
PersonalDataErasureRequest.owner_id == owner_id
)
) is not None
def test_delete_api_uses_service_principal_actor_and_ignores_body_identity(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_KEY", "service-key")
monkeypatch.setenv("API_ACTOR", "service-admin")
monkeypatch.setenv("API_KEYS", "[]")
get_settings.cache_clear()
seen: dict[str, str] = {}
def fake_erase(
_service: FeishuPersonalDataService,
code: str,
*,
actor: str,
) -> ErasureResult:
seen.update(code=code, actor=actor)
return ErasureResult(
anonymous_id="anonymous-test",
deleted={"identity": 1},
)
monkeypatch.setattr(
FeishuPersonalDataService,
"erase_by_user_code",
fake_erase,
)
app = FastAPI()
app.include_router(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
client = TestClient(app)
try:
path = "/api/v1/integrations/feishu/users/FSU-target/personal-data"
assert client.delete(path).status_code == 401
assert (
client.delete(path, headers={"X-API-Key": "wrong-key"}).status_code
== 401
)
response = client.request(
"DELETE",
path,
headers={"X-API-Key": "service-key"},
json={
"actor": "forged-user",
"open_id": "ou-forged",
},
)
assert response.status_code == 200
assert response.json()["anonymous_id"] == "anonymous-test"
assert seen == {
"code": "FSU-target",
"actor": "service-admin",
}
finally:
get_settings.cache_clear()

View File

@@ -0,0 +1,365 @@
from datetime import timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, select, update
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import AIMemoryScope
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.service import AIMemoryService
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.models import FeishuUser
from app.modules.market.service import MarketService
from app.modules.personalization.constants import (
CONVERSATION_MAX_MESSAGES,
PersonalizationContextKey,
)
from app.modules.personalization.models import (
AIConversation,
AIConversationMessage,
PersonalDataErasureRequest,
UserPreference,
)
from app.modules.personalization.services import (
ConversationService,
PersonalDataErasureService,
PersonalizationContextService,
PreferenceService,
)
def _session_factory():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine, expire_on_commit=False)
def _user(db: Session, suffix: str, *, open_id: str | None = None) -> FeishuUser:
record = FeishuUser(
code=f"USR-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=open_id or f"open-{suffix}",
)
db.add(record)
db.commit()
db.refresh(record)
return record
def test_rules_memory_and_watchlists_are_owner_scoped(monkeypatch) -> None:
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
get_settings.cache_clear()
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "first", open_id="same-open-id")
second = _user(db, "second", open_id="same-open-id")
memory = AIMemoryService(db)
company_rule = memory.create_rule(
content="公司规则",
scope=AIMemoryScope.GLOBAL,
subject="company",
priority=90,
tags=[],
actor="service",
)
first_rule = memory.create_rule(
content="相同个人规则",
scope=AIMemoryScope.USER,
subject="profile",
priority=50,
tags=[],
actor="first",
owner_id=first.id,
)
second_rule = memory.create_rule(
content="相同个人规则",
scope=AIMemoryScope.USER,
subject="profile",
priority=50,
tags=[],
actor="second",
owner_id=second.id,
)
assert [item["code"] for item in memory.list_rules()] == [company_rule["code"]]
assert [item["code"] for item in memory.list_rules(owner_id=first.id)] == [
first_rule["code"]
]
assert [item["code"] for item in memory.list_rules(owner_id=second.id)] == [
second_rule["code"]
]
with pytest.raises(HTTPException) as exc_info:
memory.update_rule(
code=second_rule["code"],
content="越权修改",
priority=None,
tags=None,
enabled=None,
actor="first",
owner_id=first.id,
)
assert exc_info.value.status_code == 404
first_memory = memory.auto_write(
prompt="Remember concise project risk summaries",
context={"scope": "user", "subject": "profile"},
answer="Use concise project risk summaries.",
actor="first",
owner_id=first.id,
)
second_memory = memory.auto_write(
prompt="Remember concise project risk summaries",
context={"scope": "user", "subject": "profile"},
answer="Use concise project risk summaries.",
actor="second",
owner_id=second.id,
)
assert first_memory is not None
assert second_memory is not None
assert first_memory.code != second_memory.code
assert first_memory.fingerprint != second_memory.fingerprint
market = MarketService(db)
market.add_watchlist("same-open-id", "600000", owner_id=first.id)
market.add_watchlist("same-open-id", "600000", owner_id=second.id)
assert market.watchlist("ignored", owner_id=first.id) == [{"symbol": "600000.SH"}]
assert market.watchlist("ignored", owner_id=second.id) == [{"symbol": "600000.SH"}]
market.add_watchlist("legacy-open", "000001")
assert market.watchlist("legacy-open") == [{"symbol": "000001.SZ"}]
assert market.claim_legacy_watchlist(first.id, "legacy-open") == 1
assert market.watchlist("legacy-open") == []
assert market.watchlist("ignored", owner_id=first.id) == [
{"symbol": "600000.SH"},
{"symbol": "000001.SZ"},
]
finally:
get_settings.cache_clear()
engine.dispose()
def test_preferences_are_allowlisted_sensitive_safe_and_owner_scoped() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "pref-first")
second = _user(db, "pref-second")
service = PreferenceService(db)
first_pref = service.upsert(first.id, "tone", "简洁直接")
second_pref = service.upsert(second.id, "tone", "简洁直接")
assert first_pref["code"] != second_pref["code"]
assert service.list_preferences(first.id) == [first_pref]
with pytest.raises(HTTPException) as exc_info:
service.delete(first.id, second_pref["code"])
assert exc_info.value.status_code == 404
with pytest.raises(HTTPException) as exc_info:
service.upsert(first.id, "topic", "记住我的银行账号 123456")
assert exc_info.value.status_code == 422
assert (
service.save_auto_extraction(
first.id,
provider_name="noop",
user_text="以后请用中文",
structured_payload={
"preferences": [{"category": "language", "value": "中文"}]
},
)
== []
)
assert (
service.save_auto_extraction(
first.id,
provider_name="direct_llm",
user_text="今天怎么样",
structured_payload={
"preferences": [{"category": "language", "value": "中文"}]
},
)
== []
)
saved = service.save_auto_extraction(
first.id,
provider_name="direct_llm",
user_text="以后请用中文,并记住我的健康诊断",
structured_payload={
"preferences": [
{"category": "language", "value": "中文"},
{"category": "topic", "value": "我的健康诊断"},
]
},
)
assert [(item["category"], item["value"]) for item in saved] == [
("language", "中文")
]
finally:
engine.dispose()
def test_conversations_keep_twenty_turns_and_isolate_sessions() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "conversation-first")
second = _user(db, "conversation-second")
service = ConversationService(db)
assert (
service.record_turn(
second.id,
"private",
"chat-1",
user_content="placeholder",
assistant_content="placeholder",
provider_name="noop",
)
is False
)
for index in range(21):
assert service.record_turn(
first.id,
"private",
"chat-1",
user_content=f"user-{index}",
assistant_content=f"assistant-{index}",
provider_name="direct_llm",
)
history = service.history(first.id, "private", "chat-1")
assert len(history) == CONVERSATION_MAX_MESSAGES
assert history[0]["content"] == "user-1"
assert history[-1]["content"] == "assistant-20"
assert service.history(second.id, "private", "chat-1") == []
assert service.provider_session_id(first.id, "private", "chat-1") == (
service.provider_session_id(first.id, "p2p", "chat-1")
)
assert service.provider_session_id(first.id, "group", "chat-1") != (
service.provider_session_id(second.id, "group", "chat-1")
)
conversation = db.execute(
select(AIConversation).where(AIConversation.owner_id == first.id)
).scalar_one()
db.execute(
update(AIConversationMessage)
.where(AIConversationMessage.conversation_id == conversation.id)
.values(created_at=utc_now() - timedelta(days=31))
)
db.commit()
assert service.history(first.id, "private", "chat-1") == []
finally:
engine.dispose()
def test_context_order_and_erasure_core_hooks() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
owner = _user(db, "context-owner")
other = _user(db, "context-other")
memory = AIMemoryService(db)
memory.create_rule(
content="公司规则优先",
scope="global",
subject="company",
priority=100,
tags=[],
actor="service",
)
memory.create_rule(
content="个人规则",
scope="global",
subject="profile",
priority=80,
tags=[],
actor="owner",
owner_id=owner.id,
)
PreferenceService(db).upsert(owner.id, "tone", "简洁")
PreferenceService(db).upsert(owner.id, "interest", "人工智能")
MarketService(db).add_watchlist("owner-open", "600000", owner_id=owner.id)
ConversationService(db).record_turn(
owner.id,
"private",
"chat-context",
user_content="上一问",
assistant_content="上一答",
provider_name="direct_llm",
)
context = PersonalizationContextService(db).build(
owner_id=owner.id,
request="当前请求",
system_constraints="只读安全规则",
chat_type="private",
chat_key="chat-context",
subject="profile",
actor="owner",
)
assert [str(key) for key in context.as_ordered_dict()] == [
PersonalizationContextKey.SYSTEM_CONSTRAINTS,
PersonalizationContextKey.COMPANY_RULES,
PersonalizationContextKey.PERSONAL_RULES,
PersonalizationContextKey.CURRENT_REQUEST,
PersonalizationContextKey.PREFERENCES,
PersonalizationContextKey.INTERESTS,
PersonalizationContextKey.PERSONAL_MEMORY,
PersonalizationContextKey.CONVERSATION_HISTORY,
]
assert context.company_rules[0]["rule"] == "公司规则优先"
assert context.personal_rules[0]["rule"] == "个人规则"
assert {item["value"] for item in context.interests} == {
"人工智能",
"600000.SH",
}
assert context.conversation_history[-1]["content"] == "上一答"
confirmation = PersonalDataErasureService(db).request_confirmation(owner.id)
with pytest.raises(HTTPException):
PersonalDataErasureService(db).confirm_and_erase(owner.id, "BAD-CODE")
seen: dict[str, str | int] = {}
def integration_hook(
_db: Session,
owner_id: int,
anonymous_id: str,
) -> dict[str, int]:
seen.update(owner_id=owner_id, anonymous_id=anonymous_id)
return {"subscriptions": 0}
erased = PersonalDataErasureService(db).confirm_and_erase(
owner.id,
confirmation.confirmation_code,
extra_hooks=(integration_hook,),
)
assert erased.deleted["preferences"] == 2
assert erased.deleted["conversations"] == 1
assert erased.deleted["ai_memory"] == 1
assert erased.deleted["watchlist"] == 1
assert seen["owner_id"] == owner.id
assert seen["anonymous_id"] == erased.anonymous_id
assert db.scalar(
select(UserPreference).where(UserPreference.owner_id == owner.id)
) is None
assert db.scalar(
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner.id)
) is None
assert db.scalar(
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner.id)
) is None
assert db.scalar(
select(PersonalDataErasureRequest).where(
PersonalDataErasureRequest.owner_id == owner.id
)
) is None
assert db.get(FeishuUser, other.id) is not None
assert memory.active_rules()[0]["rule"] == "公司规则优先"
finally:
engine.dispose()

View File

@@ -0,0 +1,167 @@
from collections.abc import Iterator
from datetime import timedelta
import pytest
from fastapi import FastAPI
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
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.modules.feishu_users.models import FeishuUser
from app.modules.personalization.models import AIConversation, AIConversationMessage
from app.modules.personalization.services import ConversationService
@pytest.fixture(autouse=True)
def _reset_settings() -> Iterator[None]:
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def session_factory() -> Iterator[sessionmaker[Session]]:
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()
def test_global_cleanup_removes_inactive_user_history(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
expired = _conversation(db, "expired")
mixed = _conversation(db, "mixed")
fresh = _conversation(db, "fresh")
old_time = utc_now() - timedelta(days=31)
fresh_time = utc_now() - timedelta(days=29)
db.add_all(
[
AIConversationMessage(
conversation_id=expired.id,
role="user",
content="expired question",
created_at=old_time,
),
AIConversationMessage(
conversation_id=expired.id,
role="assistant",
content="expired answer",
created_at=old_time,
),
AIConversationMessage(
conversation_id=mixed.id,
role="user",
content="old mixed question",
created_at=old_time,
),
AIConversationMessage(
conversation_id=mixed.id,
role="assistant",
content="fresh mixed answer",
created_at=fresh_time,
),
AIConversationMessage(
conversation_id=fresh.id,
role="user",
content="fresh question",
created_at=fresh_time,
),
]
)
db.commit()
deleted = ConversationService(db).cleanup_expired_globally()
assert deleted == {
"conversation_messages": 3,
"conversations": 1,
}
assert db.get(AIConversation, expired.id) is None
assert db.get(AIConversation, mixed.id) is not None
assert db.get(AIConversation, fresh.id) is not None
assert db.scalar(
select(func.count()).select_from(AIConversationMessage)
) == 2
with session_factory() as verification_db:
assert verification_db.get(AIConversation, expired.id) is None
@pytest.mark.parametrize(
("features_enabled", "job_expected"),
[(False, False), (True, True)],
)
def test_scheduler_wires_one_global_retention_job(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
features_enabled: bool,
job_expected: bool,
) -> None:
monkeypatch.setenv(
"FEISHU_USER_FEATURES_ENABLED",
str(features_enabled).lower(),
)
monkeypatch.setattr("app.core.database.SessionLocal", session_factory)
get_settings.cache_clear()
app = FastAPI()
scheduler = create_scheduler(app)
job = scheduler.get_job("personalization_retention_cleanup")
assert (job is not None) is job_expected
if job is None:
return
assert job.trigger.interval == timedelta(minutes=1)
with session_factory() as db:
expired = _conversation(db, "scheduled")
db.add(
AIConversationMessage(
conversation_id=expired.id,
role="user",
content="expired scheduled message",
created_at=utc_now() - timedelta(days=31),
)
)
db.commit()
expired_id = expired.id
job.func()
assert app.state.last_personalization_retention_cleanup == {
"conversation_messages": 1,
"conversations": 1,
}
with session_factory() as db:
assert db.get(AIConversation, expired_id) is None
def _conversation(db: Session, suffix: str) -> AIConversation:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
)
db.add(owner)
db.flush()
conversation = AIConversation(
owner_id=owner.id,
chat_type="private",
chat_key=f"chat-{suffix}",
)
db.add(conversation)
db.flush()
return conversation

View File

@@ -0,0 +1,361 @@
from typing import Any
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.database import Base
from app.modules.ai_agent.adapters.common import _ordered_context
from app.modules.ai_agent.constants import (
PREFERENCE_EXTRACTION_INSTRUCTIONS,
AIContextKey,
AIExecutionMode,
AIResponseKey,
)
from app.modules.ai_agent.service import AIService
from app.modules.ai_memory.constants import AIMemoryKind
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.service import AIMemoryService
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.models import FeishuUser
from app.modules.market.service import MarketService
from app.modules.personalization.models import (
AIConversation,
AIConversationMessage,
UserPreference,
)
from app.modules.personalization.services import ConversationService, PreferenceService
class CapturingAdapter:
provider_name = "direct_llm"
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def ask(
self,
prompt: str,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
self.calls.append((prompt, dict(context or {})))
if prompt == PREFERENCE_EXTRACTION_INSTRUCTIONS:
return {
AIResponseKey.ANSWER: (
'{"preferences":[{"category":"language","value":"中文"}]}'
),
AIResponseKey.RAW: {},
}
return {
AIResponseKey.ANSWER: "personalized answer",
AIResponseKey.RAW: {"request": len(self.calls)},
}
class FailingNoopAdapter:
provider_name = "noop"
def ask(
self,
prompt: str,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
raise AssertionError("noop must short-circuit before adapter.ask")
def _session_factory():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine, expire_on_commit=False)
def _user(db: Session, suffix: str) -> FeishuUser:
record = FeishuUser(
code=f"USR-AI-{suffix}",
tenant_key=f"tenant-ai-{suffix}",
open_id=f"open-ai-{suffix}",
)
db.add(record)
db.commit()
db.refresh(record)
return record
def _rule(
db: Session,
content: str,
*,
owner_id: int | None = None,
) -> dict[str, Any]:
return AIMemoryService(db).create_rule(
content=content,
scope="global",
subject="profile",
priority=80,
tags=[],
actor="pytest",
owner_id=owner_id,
)
def test_personalized_ai_uses_ordered_owner_context_and_persists_after_success(
monkeypatch,
) -> None:
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
get_settings.cache_clear()
engine, factory = _session_factory()
adapter = CapturingAdapter()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: adapter)
try:
with factory() as db:
owner = _user(db, "owner")
other = _user(db, "other")
_rule(db, "company rule")
_rule(db, "owner personal rule", owner_id=owner.id)
_rule(db, "other personal rule", owner_id=other.id)
PreferenceService(db).upsert(owner.id, "tone", "简洁")
PreferenceService(db).upsert(owner.id, "interest", "风险管理")
MarketService(db).add_watchlist("owner", "600000", owner_id=owner.id)
AIMemoryService(db).auto_write(
prompt="risk preference",
context={"scope": "user", "subject": f"owner:{owner.id}"},
answer="remember owner risk preference",
owner_id=owner.id,
actor="owner",
)
ConversationService(db).record_turn(
owner.id,
"private",
"chat-personal",
user_content="previous question",
assistant_content="previous answer",
provider_name="direct_llm",
)
response = AIService(db).ask_personalized(
owner.id,
"private",
"chat-personal",
"risk preference 以后请用中文",
actor="owner",
)
assert response[AIResponseKey.OK] is True
assert response[AIResponseKey.ANSWER] == "personalized answer"
assert len(adapter.calls) == 2
prompt, context = adapter.calls[0]
assert prompt == "risk preference 以后请用中文"
assert context[AIContextKey.COMPANY_RULES][0]["rule"] == "company rule"
assert context[AIContextKey.PERSONAL_RULES][0]["rule"] == (
"owner personal rule"
)
assert "other personal rule" not in str(context)
assert context[AIContextKey.PREFERENCES][0]["value"] == "简洁"
assert {item["value"] for item in context[AIContextKey.INTERESTS]} == {
"风险管理",
"600000.SH",
}
assert context[AIContextKey.LOCAL_MEMORY]
assert len(context[AIContextKey.CONVERSATION_HISTORY]) == 2
assert context[AIContextKey.PROVIDER_SESSION_ID] == (
ConversationService.provider_session_id(
owner.id,
"private",
"chat-personal",
)
)
assert context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
assert context[AIContextKey.EXECUTION_MODE] == AIExecutionMode.PERSONALIZED
assert AIContextKey.OPENCLAW_TOOL not in context
serialized = _ordered_context(prompt, context)
headings = [
"公司规则:",
"个人规则:",
"当前请求:",
"个人偏好与兴趣:",
"个人相关记忆:",
"当前会话历史:",
]
positions = [serialized.index(heading) for heading in headings]
assert positions == sorted(positions)
extraction_context = adapter.calls[1][1]
assert extraction_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
assert extraction_context[AIContextKey.EXECUTION_MODE] == (
AIExecutionMode.PREFERENCE_EXTRACTION
)
assert db.scalar(
select(func.count())
.select_from(AIConversationMessage)
.join(
AIConversation,
AIConversation.id == AIConversationMessage.conversation_id,
)
.where(AIConversation.owner_id == owner.id)
) == 4
owner_memory = db.scalar(
select(func.count())
.select_from(AIMemoryEntry)
.where(
AIMemoryEntry.owner_id == owner.id,
AIMemoryEntry.kind == AIMemoryKind.MEMORY,
)
)
assert owner_memory == 2
assert {
item["category"] for item in PreferenceService(db).list_preferences(owner.id)
} == {"tone", "interest", "language"}
finally:
get_settings.cache_clear()
engine.dispose()
def test_noop_returns_unavailable_without_personal_or_company_memory_writes(
monkeypatch,
) -> None:
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
get_settings.cache_clear()
engine, factory = _session_factory()
monkeypatch.setattr(
"app.modules.ai_agent.service.get_adapter",
lambda: FailingNoopAdapter(),
)
try:
with factory() as db:
owner = _user(db, "noop")
personalized = AIService(db).ask_personalized(
owner.id,
"private",
"chat-noop",
"以后请用中文",
actor="owner",
)
internal = AIService(db).ask("remember this internal request")
assert personalized[AIResponseKey.OK] is False
assert internal[AIResponseKey.OK] is False
assert "不可用" in personalized[AIResponseKey.ANSWER]
assert db.scalar(select(func.count()).select_from(UserPreference)) == 0
assert db.scalar(select(func.count()).select_from(AIConversationMessage)) == 0
assert db.scalar(select(func.count()).select_from(AIMemoryEntry)) == 0
with pytest.raises(HTTPException) as exc_info:
AIService(db).ask(
"forged context",
context={AIContextKey.COMPANY_RULES: [{"rule": "forged"}]},
)
assert exc_info.value.status_code == 403
finally:
get_settings.cache_clear()
engine.dispose()
def test_scheduled_generation_has_strict_context_and_no_personal_side_effects(
monkeypatch,
) -> None:
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
get_settings.cache_clear()
engine, factory = _session_factory()
adapter = CapturingAdapter()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: adapter)
try:
with factory() as db:
owner = _user(db, "scheduled")
_rule(db, "scheduled company rule")
_rule(db, "scheduled personal rule", owner_id=owner.id)
PreferenceService(db).upsert(owner.id, "tone", "简洁")
MarketService(db).add_watchlist("scheduled", "000001", owner_id=owner.id)
AIMemoryService(db).auto_write(
prompt="scheduled risk",
context={"scope": "user", "subject": f"owner:{owner.id}"},
answer="scheduled owner memory",
owner_id=owner.id,
actor="owner",
)
ConversationService(db).record_turn(
owner.id,
"private",
"chat-scheduled",
user_content="do not load",
assistant_content="do not load",
provider_name="direct_llm",
)
before = {
"memory": db.scalar(select(func.count()).select_from(AIMemoryEntry)),
"preferences": db.scalar(
select(func.count()).select_from(UserPreference)
),
"messages": db.scalar(
select(func.count()).select_from(AIConversationMessage)
),
"watchlist": db.scalar(
select(func.count()).select_from(MarketWatchlist)
),
}
private = AIService(db).generate_scheduled(
"scheduled risk",
owner_id=owner.id,
group=False,
actor="subscription-system",
)
group = AIService(db).generate_scheduled(
"scheduled group report",
owner_id=owner.id,
group=True,
actor="subscription-system",
)
assert private[AIResponseKey.OK] is True
assert group[AIResponseKey.OK] is True
assert len(adapter.calls) == 2
private_context = adapter.calls[0][1]
assert private_context[AIContextKey.COMPANY_RULES] == []
assert private_context[AIContextKey.PERSONAL_RULES][0]["rule"] == (
"scheduled personal rule"
)
assert private_context[AIContextKey.PREFERENCES]
assert private_context[AIContextKey.INTERESTS]
assert private_context[AIContextKey.LOCAL_MEMORY]
assert private_context[AIContextKey.CONVERSATION_HISTORY] == []
assert AIContextKey.PROVIDER_SESSION_ID not in private_context
assert private_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
assert private_context[AIContextKey.EXECUTION_MODE] == (
AIExecutionMode.SCHEDULED_PRIVATE
)
group_context = adapter.calls[1][1]
assert group_context[AIContextKey.COMPANY_RULES][0]["rule"] == (
"scheduled company rule"
)
assert group_context[AIContextKey.PERSONAL_RULES] == []
assert group_context[AIContextKey.PREFERENCES] == []
assert group_context[AIContextKey.INTERESTS] == []
assert group_context[AIContextKey.LOCAL_MEMORY] == []
assert group_context[AIContextKey.CONVERSATION_HISTORY] == []
assert group_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
assert group_context[AIContextKey.EXECUTION_MODE] == (
AIExecutionMode.SCHEDULED_GROUP
)
after = {
"memory": db.scalar(select(func.count()).select_from(AIMemoryEntry)),
"preferences": db.scalar(
select(func.count()).select_from(UserPreference)
),
"messages": db.scalar(
select(func.count()).select_from(AIConversationMessage)
),
"watchlist": db.scalar(
select(func.count()).select_from(MarketWatchlist)
),
}
assert after == before
finally:
get_settings.cache_clear()
engine.dispose()

View File

@@ -1,8 +1,5 @@
import json
import os
import tempfile
from datetime import date, datetime, timedelta
from pathlib import Path
import pytest
from fastapi import HTTPException
@@ -11,22 +8,6 @@ from sqlalchemy import select
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import BusinessResponseKey, StatusValue
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
_db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["AUDIT_API_KEY"] = "audit-key"
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
@@ -153,13 +134,6 @@ def create_business_record(domain: str, data: dict, actor: str = "pytest") -> Se
db.close()
def teardown_module() -> None:
engine.dispose()
path = Path(_db.name)
if path.exists():
path.unlink()
def test_project_report_and_feishu_command_preview() -> None:
response = create_business_record(
"projects",
@@ -449,7 +423,7 @@ def test_v3_event_retry_and_dispatch_pending_route() -> None:
assert dispatched[0]["status"] == EventStatus.PROCESSED
def test_v3_ai_memory_recall_and_auto_write() -> None:
def test_v3_ai_noop_does_not_auto_write_memory() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
@@ -464,45 +438,33 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
assert response.status_code == 200
data = response.json()
assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP
assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == (
AIMemoryStatus.ACTIVE
)
assert data[AIResponseKey.OK] is False
assert AIResponseKey.MEMORY_WRITE not in data[AIResponseKey.RAW]
list_response = client.get(
"/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE",
headers=headers,
)
assert list_response.status_code == 200
assert list_response.json()[AIMemoryResponseKey.ITEMS]
recall_response = client.post(
"/api/v1/ai/memory/recall",
headers=headers,
json={
"query": "concise bullet summaries",
"scope": "project",
"subject": "P-MEM-SMOKE",
},
)
assert recall_response.status_code == 200
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
assert list_response.json()[AIMemoryResponseKey.ITEMS] == []
def test_ai_memory_rejects_financial_facts_and_applies_retention() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "Remember the project cash flow and budget details for next quarter",
"context": {
db = SessionLocal()
try:
memory = AIMemoryService(db).auto_write(
prompt="Remember the project cash flow and budget details for next quarter",
context={
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-FINANCIAL",
},
},
)
assert response.status_code == 200
memory_write = response.json()[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE]
assert memory_write[AIMemoryPayloadKey.STATUS] == AIMemoryStatus.REJECTED
answer="Retain the confidential cash flow and budget details.",
actor="pytest",
)
assert memory is not None
assert memory.status == AIMemoryStatus.REJECTED
finally:
db.close()
rejected = client.get(
"/api/v1/ai/memory",
@@ -531,7 +493,9 @@ def test_default_json_response_masks_sensitive_fields() -> None:
assert payload["nested"]["status"] == "ok"
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
def test_v3_risk_action_routes_write_local_state_in_read_only_mode(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
response = create_business_record(
"risk-events",
{
@@ -549,10 +513,14 @@ def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
headers=headers,
json={"assigned_to": "risk-owner", "comment": "route to owner"},
)
assert response.status_code == 405
assert response.status_code == 200
assert response.json()["risk_event"]["assigned_to"] == "risk-owner"
def test_writeback_and_approval_routes_are_removed() -> None:
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
def test_writeback_and_approval_routes_are_removed(monkeypatch, read_only_mode: str) -> None:
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
get_settings.cache_clear()
response = client.post(
"/api/v1/writebacks",
headers=headers,
@@ -577,6 +545,7 @@ def test_writeback_and_approval_routes_are_removed() -> None:
},
)
assert response.status_code == 404
get_settings.cache_clear()
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
@@ -687,6 +656,7 @@ def test_dashboard_and_response_masking() -> None:
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
assert dashboard_response.status_code == 200
assert "metrics" in dashboard_response.json()
assert "latest_audit_logs" not in dashboard_response.json()
def test_configured_domain_response_masking(monkeypatch) -> None:
@@ -714,7 +684,10 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
get_settings.cache_clear()
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
def test_business_write_routes_are_permanently_disabled(monkeypatch, read_only_mode: str) -> None:
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
get_settings.cache_clear()
create_response = client.post(
"/api/v1/business/projects",
headers=headers,
@@ -737,9 +710,15 @@ def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
},
)
assert update_response.status_code == 405
get_settings.cache_clear()
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
def test_approval_and_feishu_approval_card_routes_are_removed(
monkeypatch, read_only_mode: str
) -> None:
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
get_settings.cache_clear()
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
@@ -768,9 +747,30 @@ def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
},
)
assert callback_response.status_code == 404
openclaw_response = client.post(
"/api/v1/ai/openclaw/tools/invoke",
headers=headers,
json={
"tool": "sessions_list",
"action": "json",
"args": {},
"session_key": "main",
},
)
assert openclaw_response.status_code == 404
assert "/api/v1/approvals" not in app.openapi()["paths"]
assert "/api/v1/integrations/feishu/approval-card-action" not in app.openapi()["paths"]
assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"]
get_settings.cache_clear()
def test_new_ledgers_reports_and_risk_events() -> None:
def test_new_ledgers_reports_and_local_risk_state(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
monkeypatch.setattr(
"app.modules.risk.routes.enqueue_risk_event_generation",
lambda actor: {"queued": True, "actor": actor},
)
domains_response = client.get("/api/v1/business/domains", headers=headers)
assert domains_response.status_code == 200
domains = domains_response.json()["domains"]
@@ -810,20 +810,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
report_response = client.post(
"/api/v1/reports/work-reports/generate",
headers=headers,
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
json={
"report_type": ReportType.DAILY,
"reporter": "pytest",
"actor": "pytest",
"persist": True,
},
)
assert report_response.status_code == 200
assert report_response.json()["data"] is None
assert report_response.json()["data"]["reporter"] == "pytest"
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
risk_response = client.post(
"/api/v1/risks/events/generate?actor=pytest",
headers=headers,
)
assert risk_response.status_code == 405
assert risk_response.status_code == 200
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
assert enqueue_response.status_code == 405
assert enqueue_response.status_code == 200
assert enqueue_response.json()["queued"] is True
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
assert overdue_response.status_code == 200
@@ -1004,8 +1010,9 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
)
assert ai_response.status_code == 200
ai_data = ai_response.json()
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is False
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP
assert "不可用" in ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.ANSWER]
def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
@@ -1101,7 +1108,9 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
assert metrics["expenses_pending"] == 1
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
def test_legacy_read_query_and_local_import_allowed_in_read_only_mode(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
rows = [
{
"id": 9001,
@@ -1138,10 +1147,15 @@ def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
"field_map": {"title": "task_name"},
},
)
assert response.status_code == 405
assert response.status_code == 200
assert response.json()["dry_run"] is False
assert response.json()["items"][0]["task"]["source_system"] == "legacy_mysql"
get_settings.cache_clear()
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
def test_risk_event_actions_write_local_state_in_read_only_mode(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
create_response = create_business_record(
"risk-events",
{
@@ -1162,21 +1176,21 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
headers=headers,
json={"assigned_to": "risk-owner", "comment": "please handle"},
)
assert assign_response.status_code == 405
assert assign_response.status_code == 200
comment_response = client.post(
f"/api/v1/risks/events/{event_id}/comment",
headers=headers,
json={"comment": "working on it", "payload": {"step": 1}},
)
assert comment_response.status_code == 405
assert comment_response.status_code == 200
resolve_response = client.post(
f"/api/v1/risks/events/{event_id}/resolve",
headers=headers,
json={"comment": "resolved"},
)
assert resolve_response.status_code == 405
assert resolve_response.status_code == 200
close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
@@ -1186,21 +1200,22 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
"review_summary": "handled",
},
)
assert close_response.status_code == 405
assert close_response.status_code == 200
reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck"},
)
assert reopen_response.status_code == 405
assert reopen_response.status_code == 200
actions_response = client.get(
f"/api/v1/risks/events/{event_id}/actions",
headers=headers,
)
assert actions_response.status_code == 200
assert actions_response.json()["items"] == []
assert len(actions_response.json()["items"]) == 5
get_settings.cache_clear()
def test_report_push_failure_is_recorded() -> None:
@@ -1278,7 +1293,8 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
def mappings(self) -> "FakeResult":
return self
def all(self) -> list:
def fetchmany(self, size: int) -> list:
_ = size
return []
class FakeConnection:
@@ -1486,7 +1502,7 @@ def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() ->
def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "false")
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
monkeypatch.setattr(
IntasectSyncService,
@@ -1524,16 +1540,21 @@ def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
get_settings.cache_clear()
def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None:
def test_lifecycle_enqueue_allows_local_work_in_read_only_mode(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
monkeypatch.setattr(
"app.modules.reports.routes.enqueue_lifecycle_report",
lambda **kwargs: {"status": "queued", "report_type": kwargs["report_type"]},
)
try:
response = client.post(
"/api/v1/reports/lifecycle/enqueue",
headers=headers,
json={"report_type": "daily"},
)
assert response.status_code == 405
assert response.status_code == 200
assert response.json()["status"] == "queued"
finally:
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()
@@ -1685,7 +1706,7 @@ def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) ->
def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "false")
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
try:
created = client.post(
@@ -2260,7 +2281,7 @@ def test_market_closed_day_skips_quote_collection() -> None:
def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
monkeypatch.setenv("READ_ONLY_MODE", "false")
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
try:
scheduler = create_scheduler()
@@ -2399,7 +2420,7 @@ def test_market_pipeline_is_idempotent_and_requires_complete_ai_chart_delivery(
monkeypatch.setenv("FEISHU_APP_ID", "test-app")
monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret")
monkeypatch.setenv("FEISHU_DEFAULT_CHAT_ID", "oc_market")
monkeypatch.setenv("READ_ONLY_MODE", "false")
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
monkeypatch.setattr(
"app.modules.feishu.service.FeishuService.upload_image",
@@ -2518,31 +2539,25 @@ def test_feishu_market_rules_are_scoped_to_market(monkeypatch) -> None:
get_settings.cache_clear()
def test_read_only_mode_blocks_feishu_mutations_and_market_pipeline(monkeypatch) -> None:
class MustNotRunMarket:
def sync_daily(self, target):
pytest.fail("read-only mode must block market synchronization")
def test_read_only_mode_allows_local_rules_and_watchlist(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
get_settings.cache_clear()
db = SessionLocal()
try:
rules = FeishuCommandService(db).handle_text(
"学习市场规则:这条规则应写入", actor="ou_read_only", auto_reply=False
"学习市场规则:这条规则应写入本地状态",
actor="ou_read_only",
auto_reply=False,
)
watchlist = FeishuCommandService(db).handle_text(
"加入自选 600105", actor="ou_read_only", auto_reply=False
)
pipeline = MarketPipelineService(db, MustNotRunMarket()).run(
"close", date(2032, 7, 12), actor="pytest"
)
assert "只读模式" in rules["content"]
assert "只读模式" in watchlist["content"]
assert pipeline["status"] == "operations_disabled"
assert "规则已学习" in rules["content"]
assert "已加入自选600105.SH" in watchlist["content"]
assert db.execute(
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则应写入")
).scalar_one_or_none() is None
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则应写入本地状态")
).scalar_one_or_none() is not None
finally:
db.close()
monkeypatch.delenv("READ_ONLY_MODE", raising=False)

View File

@@ -0,0 +1,375 @@
from datetime import datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.application.delivery.subscriptions import FeishuSubscriptionSender
from app.core.database import Base
from app.modules.feishu.errors import FeishuAPIError
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import FeishuUser
from app.modules.subscriptions.constants import (
MAX_DAILY_DELIVERIES,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
DeliveryGenerationRequest,
DeliverySendRequest,
DeliveryService,
)
class StubGenerator:
def __init__(self, content: str = "生成后的提醒") -> None:
self.content = content
self.requests: list[DeliveryGenerationRequest] = []
def generate(self, request: DeliveryGenerationRequest) -> str:
self.requests.append(request)
return self.content
class StubSender:
def __init__(self, outcomes: list[dict[str, Any] | Exception]) -> None:
self.outcomes = outcomes
self.requests: list[DeliverySendRequest] = []
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
self.requests.append(request)
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class StubFeishuService:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def send_text(self, text: str, **kwargs: Any) -> dict[str, Any]:
self.calls.append({"text": text, **kwargs})
return {"code": 0}
def _delivery_fixture(
db: Session,
*,
suffix: str,
current: datetime,
target_type: str = SubscriptionTargetType.USER,
) -> tuple[FeishuUser, PushSubscription, PushDelivery]:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=(
FeishuUserRole.ADMIN
if target_type == SubscriptionTargetType.CHAT
else FeishuUserRole.USER
),
timezone="Asia/Shanghai",
)
db.add(owner)
db.flush()
subscription = PushSubscription(
code=f"SUB-{suffix}",
owner_id=owner.id,
target_type=target_type,
target_id=(
f"chat-{suffix}"
if target_type == SubscriptionTargetType.CHAT
else owner.open_id
),
prompt="按我的偏好生成提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone=owner.timezone,
next_run_at=current + timedelta(days=1),
status=PushSubscriptionStatus.ACTIVE,
consented_at=current - timedelta(days=1),
)
db.add(subscription)
db.flush()
delivery = PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=current,
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=PushDeliveryStatus.PENDING,
next_attempt_at=current,
created_at=current,
updated_at=current,
)
db.add(delivery)
db.commit()
db.refresh(owner)
db.refresh(subscription)
db.refresh(delivery)
return owner, subscription, delivery
def test_private_delivery_uses_personal_context_open_id_and_is_idempotent() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
owner, _, delivery = _delivery_fixture(
db,
suffix="private",
current=current,
)
generator = StubGenerator()
sender = StubSender(
[{"code": 0, "data": {"message_id": "provider-message"}}]
)
service = DeliveryService(db, generator=generator, sender=sender)
sent = service.process(delivery.code, now=current)
duplicate = service.process(delivery.code, now=current)
assert sent.status == PushDeliveryStatus.SENT
assert sent.attempt_count == 1
assert sent.provider_message_id == "provider-message"
assert duplicate.status == PushDeliveryStatus.SENT
assert len(generator.requests) == 1
assert generator.requests[0] == DeliveryGenerationRequest(
prompt="按我的偏好生成提醒",
owner_id=owner.id,
use_personal_context=True,
use_company_rules=False,
)
assert len(sender.requests) == 1
assert sender.requests[0].receive_id == owner.open_id
assert sender.requests[0].receive_id_type == "open_id"
assert sender.requests[0].tenant_key == owner.tenant_key
assert sender.requests[0].uuid == delivery.message_uuid
finally:
engine.dispose()
def test_feishu_sender_forwards_delivery_tenant_key() -> None:
feishu = StubFeishuService()
sender = FeishuSubscriptionSender(feishu) # type: ignore[arg-type]
request = DeliverySendRequest(
receive_id="open-user",
receive_id_type="open_id",
tenant_key="tenant-a",
text="提醒内容",
uuid="stable-delivery-uuid",
)
result = sender.send(request)
assert result == {"code": 0}
assert feishu.calls == [
{
"text": "提醒内容",
"receive_id": "open-user",
"receive_id_type": "open_id",
"actor": "scheduler",
"uuid": "stable-delivery-uuid",
"tenant_key": "tenant-a",
}
]
def test_group_delivery_never_exposes_creator_personal_context() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
owner, subscription, delivery = _delivery_fixture(
db,
suffix="group",
current=current,
target_type=SubscriptionTargetType.CHAT,
)
generator = StubGenerator()
sender = StubSender([{"code": 0, "data": {"message_id": "group-message"}}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SENT
assert generator.requests[0].owner_id is None
assert generator.requests[0].use_personal_context is False
assert generator.requests[0].use_company_rules is True
assert generator.requests[0].allow_tools is False
assert generator.requests[0].record_history is False
assert sender.requests[0].receive_id == subscription.target_id
assert sender.requests[0].receive_id_type == "chat_id"
assert sender.requests[0].tenant_key == owner.tenant_key
finally:
engine.dispose()
def test_business_failure_retries_after_one_five_and_fifteen_minutes() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
first_at = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, _, delivery = _delivery_fixture(
db,
suffix="retry",
current=first_at,
)
generator = StubGenerator()
sender = StubSender([{"code": 999}] * 4)
service = DeliveryService(db, generator=generator, sender=sender)
first = service.process(delivery.code, now=first_at)
assert first.status == PushDeliveryStatus.RETRY
assert first.attempt_count == 1
assert first.next_attempt_at == first_at + timedelta(minutes=1)
too_early = service.process(
delivery.code,
now=first_at + timedelta(seconds=30),
)
assert too_early.attempt_count == 1
assert len(sender.requests) == 1
second_at = first_at + timedelta(minutes=1)
second = service.process(delivery.code, now=second_at)
assert second.status == PushDeliveryStatus.RETRY
assert second.attempt_count == 2
assert second.next_attempt_at == second_at + timedelta(minutes=5)
third_at = second_at + timedelta(minutes=5)
third = service.process(delivery.code, now=third_at)
assert third.status == PushDeliveryStatus.RETRY
assert third.attempt_count == 3
assert third.next_attempt_at == third_at + timedelta(minutes=15)
fourth_at = third_at + timedelta(minutes=15)
fourth = service.process(delivery.code, now=fourth_at)
assert fourth.status == PushDeliveryStatus.FAILED
assert fourth.attempt_count == 4
assert fourth.next_attempt_at is None
assert len(generator.requests) == 1
assert len({request.uuid for request in sender.requests}) == 1
finally:
engine.dispose()
def test_paused_subscription_is_skipped_without_generation_or_send() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, subscription, delivery = _delivery_fixture(
db,
suffix="paused",
current=current,
)
subscription.status = PushSubscriptionStatus.PAUSED
db.commit()
generator = StubGenerator()
sender = StubSender([{"code": 0}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SKIPPED
assert result.attempt_count == 0
assert generator.requests == []
assert sender.requests == []
finally:
engine.dispose()
def test_non_retryable_feishu_client_error_fails_without_retry() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, _, delivery = _delivery_fixture(
db,
suffix="permanent-feishu",
current=current,
)
generator = StubGenerator()
sender = StubSender(
[
FeishuAPIError(
"invalid receive target",
retryable=False,
http_status=400,
)
]
)
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.FAILED
assert result.attempt_count == 1
assert result.next_attempt_at is None
assert len(sender.requests) == 1
finally:
engine.dispose()
def test_send_time_daily_limit_counts_deferred_deliveries_by_sent_at() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
_, subscription, delivery = _delivery_fixture(
db,
suffix="send-limit",
current=current,
)
for index in range(MAX_DAILY_DELIVERIES):
db.add(
PushDelivery(
code=f"DEL-SENT-{index}",
subscription_id=subscription.id,
scheduled_for=current - timedelta(days=1, minutes=index + 1),
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=PushDeliveryStatus.SENT,
attempt_count=1,
sent_at=current - timedelta(seconds=index + 1),
created_at=current - timedelta(days=1),
updated_at=current,
)
)
db.commit()
generator = StubGenerator()
sender = StubSender([{"code": 0}])
result = DeliveryService(
db,
generator=generator,
sender=sender,
).process(delivery.code, now=current)
assert result.status == PushDeliveryStatus.SKIPPED
assert result.attempt_count == 1
assert "Daily delivery limit" in str(result.last_error)
assert sender.requests == []
finally:
engine.dispose()

View File

@@ -0,0 +1,300 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, time, timedelta
from pathlib import Path
from threading import Barrier
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.database import Base
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.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
MAX_ACTIVE_SUBSCRIPTIONS,
MAX_DAILY_DELIVERIES,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
SubscriptionManagementService,
SubscriptionScanner,
)
def _user(
db: Session,
*,
suffix: str,
role: str = FeishuUserRole.USER,
quiet_start: time | None = None,
quiet_end: time | None = None,
) -> FeishuUser:
record = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=role,
timezone="Asia/Shanghai",
quiet_hours_start=quiet_start,
quiet_hours_end=quiet_end,
)
db.add(record)
db.commit()
db.refresh(record)
return record
def _subscription(
db: Session,
owner: FeishuUser,
*,
code: str,
next_run_at: datetime,
target_type: str = SubscriptionTargetType.USER,
target_id: str | None = None,
) -> PushSubscription:
record = PushSubscription(
code=code,
owner_id=owner.id,
target_type=target_type,
target_id=target_id or owner.open_id,
prompt="给我一条简短提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone=owner.timezone,
next_run_at=next_run_at,
status=PushSubscriptionStatus.ACTIVE,
consented_at=next_run_at - timedelta(days=1),
)
db.add(record)
db.commit()
db.refresh(record)
return record
def test_management_binds_private_and_group_targets() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="user")
private_principal = FeishuPrincipal.from_user(
user,
chat_id="private-chat",
chat_type="p2p",
)
private, _ = SubscriptionManagementService(db).create_private(
private_principal,
"每天 09:00",
"给我一条提醒",
now=datetime(2026, 7, 26, 0, 0),
)
assert private.target_type == SubscriptionTargetType.USER
assert private.target_id == user.open_id
with pytest.raises(HTTPException) as ordinary_group:
SubscriptionManagementService(db).create_group(
FeishuPrincipal.from_user(
user,
chat_id="group-chat",
chat_type="group",
),
"每天 10:00",
"群提醒",
now=datetime(2026, 7, 26, 0, 0),
)
assert ordinary_group.value.status_code == 403
admin = _user(db, suffix="admin", role=FeishuUserRole.ADMIN)
with pytest.raises(HTTPException, match="current group"):
SubscriptionManagementService(db).create_group(
FeishuPrincipal.from_user(
admin,
chat_id="manually-supplied-chat",
chat_type="p2p",
),
"每天 10:00",
"群提醒",
now=datetime(2026, 7, 26, 0, 0),
)
group, _ = SubscriptionManagementService(db).create_group(
FeishuPrincipal.from_user(
admin,
chat_id="verified-current-group",
chat_type="group",
),
"每天 10:00",
"群提醒",
now=datetime(2026, 7, 26, 0, 0),
)
assert group.target_type == SubscriptionTargetType.CHAT
assert group.target_id == "verified-current-group"
finally:
engine.dispose()
def test_management_rejects_more_than_fifty_active_subscriptions() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="limit")
for index in range(MAX_ACTIVE_SUBSCRIPTIONS):
_subscription(
db,
user,
code=f"SUB-LIMIT-{index}",
next_run_at=datetime(2026, 7, 27, 1, 0),
)
with pytest.raises(HTTPException, match="at most 50"):
SubscriptionManagementService(db).create_private(
FeishuPrincipal.from_user(user),
"每天 09:00",
"第 51 条",
now=datetime(2026, 7, 26, 0, 0),
)
finally:
engine.dispose()
def test_scanner_materializes_one_window_and_advances_the_plan() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 1, 0)
try:
with Session(engine) as db:
user = _user(db, suffix="scan")
subscription = _subscription(
db,
user,
code="SUB-SCAN",
next_run_at=current,
)
first = SubscriptionScanner(db).scan_due(now=current)
second = SubscriptionScanner(db).scan_due(now=current)
assert len(first) == 1
assert second == []
assert first[0].scheduled_for == current
assert first[0].status == PushDeliveryStatus.PENDING
assert first[0].next_attempt_at == current
db.refresh(subscription)
assert subscription.next_run_at == datetime(2026, 7, 27, 1, 0)
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
finally:
engine.dispose()
def test_scanner_defers_quiet_hours_and_skips_daily_limit() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
current = datetime(2026, 7, 26, 15, 30)
try:
with Session(engine) as db:
quiet_user = _user(
db,
suffix="quiet",
quiet_start=time(22, 0),
quiet_end=time(7, 0),
)
_subscription(
db,
quiet_user,
code="SUB-QUIET",
next_run_at=current,
)
quiet_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
assert quiet_delivery.status == PushDeliveryStatus.PENDING
assert quiet_delivery.next_attempt_at == datetime(2026, 7, 26, 23, 0)
limited_user = _user(db, suffix="daily-limit")
history = _subscription(
db,
limited_user,
code="SUB-HISTORY",
next_run_at=current + timedelta(days=1),
)
for index in range(MAX_DAILY_DELIVERIES):
db.add(
PushDelivery(
code=f"DEL-HISTORY-{index}",
subscription_id=history.id,
scheduled_for=current - timedelta(minutes=index),
idempotency_key=f"{index:064x}",
message_uuid=str(uuid4()),
status=PushDeliveryStatus.SENT,
attempt_count=1,
sent_at=current,
created_at=current,
updated_at=current,
)
)
_subscription(
db,
limited_user,
code="SUB-OVER-LIMIT",
next_run_at=current,
)
db.commit()
limited_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
assert limited_delivery.status == PushDeliveryStatus.SKIPPED
assert limited_delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
finally:
engine.dispose()
def test_two_sqlite_scanners_claim_only_one_delivery(tmp_path: Path) -> None:
database_path = tmp_path / "subscription-scanner.db"
engine = create_engine(
f"sqlite:///{database_path}",
connect_args={"check_same_thread": False, "timeout": 10},
)
with engine.begin() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
current = datetime(2026, 7, 26, 1, 0)
try:
with factory() as db:
user = _user(db, suffix="concurrent")
_subscription(
db,
user,
code="SUB-CONCURRENT",
next_run_at=current,
)
barrier = Barrier(2)
def scan(worker_id: str) -> list[str]:
with factory() as db:
barrier.wait()
return [
delivery.code
for delivery in SubscriptionScanner(db).scan_due(
now=current,
worker_id=worker_id,
)
]
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(scan, ("scanner-one", "scanner-two")))
assert sum(len(items) for items in results) == 1
with factory() as db:
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
finally:
engine.dispose()

View File

@@ -0,0 +1,360 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.core.background.task_queue.subscriptions as subscription_queue
from app.application.scheduling import create_scheduler
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.modules.feishu_users.models import FeishuUser
from app.modules.observability.constants import (
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.routes import router as observability_router
from app.modules.subscriptions.constants import (
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.routes import router as subscriptions_router
@pytest.fixture(autouse=True)
def _reset_settings() -> Iterator[None]:
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("API_KEY", "subscription-service-key")
monkeypatch.setenv("API_KEYS", "[]")
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()
def _seed_subscription(
db: Session,
*,
suffix: str,
status_value: str = PushSubscriptionStatus.ACTIVE,
) -> PushSubscription:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
)
db.add(owner)
db.flush()
subscription = PushSubscription(
code=f"SUB-{suffix}",
owner_id=owner.id,
target_type=SubscriptionTargetType.USER,
target_id=owner.open_id,
prompt="生成一条个人提醒",
schedule_type=SubscriptionScheduleType.DAILY,
schedule_config={"hour": 9, "minute": 0},
timezone="Asia/Shanghai",
next_run_at=(
datetime(2030, 1, 1, 1, 0)
if status_value == PushSubscriptionStatus.ACTIVE
else None
),
status=status_value,
consented_at=datetime(2026, 7, 26, 1, 0),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
def _seed_delivery(
db: Session,
subscription: PushSubscription,
*,
suffix: str,
status_value: str,
) -> PushDelivery:
delivery = PushDelivery(
code=f"DEL-{suffix}",
subscription_id=subscription.id,
scheduled_for=datetime(2026, 7, 27, 1, 0),
idempotency_key=uuid4().hex + uuid4().hex,
message_uuid=str(uuid4()),
status=status_value,
attempt_count=2,
next_attempt_at=datetime(2030, 1, 1, 1, 0),
last_error="temporary provider failure",
)
db.add(delivery)
db.commit()
db.refresh(delivery)
return delivery
def _test_app(
session_factory: sessionmaker[Session],
*,
include_subscriptions: bool = False,
include_observability: bool = False,
) -> FastAPI:
app = FastAPI()
if include_subscriptions:
app.include_router(
subscriptions_router,
prefix="/api/v1/subscriptions",
)
if include_observability:
app.include_router(observability_router, prefix="/api/v1")
def override_db() -> Iterator[Session]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return app
def test_subscription_internal_apis_require_key_and_serialize_records(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
subscription = _seed_subscription(db, suffix="api")
delivery = _seed_delivery(
db,
subscription,
suffix="api",
status_value=PushDeliveryStatus.RETRY,
)
client = TestClient(
_test_app(session_factory, include_subscriptions=True)
)
assert client.get("/api/v1/subscriptions").status_code == 401
assert client.get("/api/v1/subscriptions/deliveries").status_code == 401
assert (
client.get(
"/api/v1/subscriptions",
headers={"X-API-Key": "invalid-key"},
).status_code
== 401
)
headers = {"X-API-Key": "subscription-service-key"}
subscriptions_response = client.get(
"/api/v1/subscriptions",
headers=headers,
)
deliveries_response = client.get(
"/api/v1/subscriptions/deliveries",
headers=headers,
)
assert subscriptions_response.status_code == 200
subscriptions_body = subscriptions_response.json()
assert subscriptions_body["total"] == 1
subscription_item = subscriptions_body["items"][0]
assert set(subscription_item) == {
"code",
"owner_id",
"target_type",
"target_id",
"prompt",
"schedule_type",
"schedule_config",
"timezone",
"next_run_at",
"status",
"consented_at",
"last_run_at",
"created_at",
"updated_at",
}
assert subscription_item["code"] == subscription.code
assert subscription_item["schedule_config"] == {"hour": 9, "minute": 0}
assert subscription_item["next_run_at"] is not None
assert deliveries_response.status_code == 200
deliveries_body = deliveries_response.json()
assert deliveries_body["total"] == 1
delivery_item = deliveries_body["items"][0]
assert set(delivery_item) == {
"code",
"subscription_id",
"scheduled_for",
"idempotency_key",
"message_uuid",
"status",
"attempt_count",
"next_attempt_at",
"provider_message_id",
"last_error",
"sent_at",
"created_at",
"updated_at",
}
assert delivery_item["code"] == delivery.code
assert delivery_item["message_uuid"] == delivery.message_uuid
assert delivery_item["attempt_count"] == 2
assert "rendered_content" not in delivery_item
@pytest.mark.parametrize(
("features_enabled", "job_expected"),
[(False, False), (True, True)],
)
def test_subscription_scheduler_job_follows_feature_flag(
monkeypatch: pytest.MonkeyPatch,
features_enabled: bool,
job_expected: bool,
) -> None:
monkeypatch.setenv(
"FEISHU_USER_FEATURES_ENABLED",
str(features_enabled).lower(),
)
get_settings.cache_clear()
scheduler = create_scheduler()
job = scheduler.get_job("subscription_delivery_cycle")
assert scheduler.running is False
assert (job is not None) is job_expected
if job is not None:
assert job.trigger.interval == timedelta(minutes=1)
assert scheduler._job_defaults["coalesce"] is True
assert scheduler._job_defaults["max_instances"] == 1
def test_subscription_queue_runs_inline_without_celery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
get_settings.cache_clear()
calls: list[str] = []
def fake_cycle(*, actor: str) -> dict[str, list[str]]:
calls.append(actor)
return {"created": [], "processed": []}
monkeypatch.setattr(
subscription_queue,
"run_subscription_cycle",
fake_cycle,
)
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
assert result == {
"queued": False,
"mode": "inline",
"task_name": "subscriptions.run_cycle",
"result": {"created": [], "processed": []},
}
assert calls == ["runtime-test"]
def test_subscription_queue_uses_celery_when_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.tasks import celery_app
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
get_settings.cache_clear()
calls: dict[str, object] = {}
class FakeSignature:
def apply_async(self) -> SimpleNamespace:
calls["applied"] = True
return SimpleNamespace(id="queued-task-id")
def fake_signature(
task_name: str,
*,
kwargs: dict[str, str],
) -> FakeSignature:
calls["task_name"] = task_name
calls["kwargs"] = kwargs
return FakeSignature()
monkeypatch.setattr(celery_app, "signature", fake_signature)
monkeypatch.setattr(
subscription_queue,
"run_subscription_cycle",
lambda **_: pytest.fail("Celery dispatch must not run inline"),
)
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
assert result == {
"queued": True,
"mode": "celery",
"task_name": "subscriptions.run_cycle",
"task_id": "queued-task-id",
}
assert calls == {
"task_name": "subscriptions.run_cycle",
"kwargs": {"actor": "runtime-test"},
"applied": True,
}
def test_ready_route_returns_503_for_processable_delivery_without_credentials(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
get_settings.cache_clear()
with session_factory() as db:
subscription = _seed_subscription(
db,
suffix="ready",
status_value=PushSubscriptionStatus.COMPLETED,
)
_seed_delivery(
db,
subscription,
suffix="ready",
status_value=PushDeliveryStatus.PENDING,
)
client = TestClient(
_test_app(session_factory, include_observability=True)
)
response = client.get("/api/v1/health/ready")
assert response.status_code == 503
body = response.json()
assert body[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
check = body[ObservabilityKey.CHECKS]["feishu_subscriptions"]
assert check[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert check["active"] == 0
assert check["processable_deliveries"] == 1
assert check["reasons"] == ["credentials_missing"]
assert "生成一条个人提醒" not in response.text

View File

@@ -0,0 +1,105 @@
from datetime import UTC, datetime, time
import pytest
from app.modules.subscriptions.constants import SubscriptionScheduleType
from app.modules.subscriptions.services.schedule import (
ScheduleParseError,
is_in_quiet_hours,
next_occurrence,
next_quiet_end,
parse_schedule,
)
NOW = datetime(2026, 7, 26, 0, 0, tzinfo=UTC)
@pytest.mark.parametrize(
("expression", "schedule_type", "next_run_at"),
[
("今天 09:00", SubscriptionScheduleType.ONCE, datetime(2026, 7, 26, 1, 0)),
("明天 9点半", SubscriptionScheduleType.ONCE, datetime(2026, 7, 27, 1, 30)),
(
"2026-08-01 10:15",
SubscriptionScheduleType.ONCE,
datetime(2026, 8, 1, 2, 15),
),
("每天 09:00", SubscriptionScheduleType.DAILY, datetime(2026, 7, 26, 1, 0)),
("工作日 09:00", SubscriptionScheduleType.WEEKDAY, datetime(2026, 7, 27, 1, 0)),
("每周一 09:00", SubscriptionScheduleType.WEEKLY, datetime(2026, 7, 27, 1, 0)),
("每月26号 09:00", SubscriptionScheduleType.MONTHLY, datetime(2026, 7, 26, 1, 0)),
(
"每隔 2 小时",
SubscriptionScheduleType.INTERVAL,
datetime(2026, 7, 26, 2, 0),
),
],
)
def test_parse_supported_chinese_schedules(
expression: str,
schedule_type: str,
next_run_at: datetime,
) -> None:
result = parse_schedule(expression, "Asia/Shanghai", now=NOW)
assert result.schedule_type == schedule_type
assert result.next_run_at == next_run_at
assert result.timezone == "Asia/Shanghai"
def test_monthly_schedule_skips_months_without_the_requested_day() -> None:
result = parse_schedule(
"每月31号 09:00",
"Asia/Shanghai",
now=datetime(2026, 4, 30, 2, 0, tzinfo=UTC),
)
assert result.next_run_at == datetime(2026, 5, 31, 1, 0)
assert (
next_occurrence(
result.schedule_type,
result.schedule_config,
result.timezone,
after=datetime(2026, 5, 31, 1, 0),
)
== datetime(2026, 7, 31, 1, 0)
)
@pytest.mark.parametrize(
"expression",
[
"每隔 14 分钟",
"有空时提醒我",
"今天 07:59",
"2026-02-30 09:00",
"每天 25:00",
],
)
def test_invalid_or_ambiguous_schedule_is_rejected(expression: str) -> None:
with pytest.raises(ScheduleParseError):
parse_schedule(expression, "Asia/Shanghai", now=NOW)
def test_invalid_iana_timezone_is_rejected() -> None:
with pytest.raises(ScheduleParseError, match="Invalid IANA timezone"):
parse_schedule("每天 09:00", "Mars/Olympus", now=NOW)
def test_quiet_hours_support_cross_midnight_windows() -> None:
current = datetime(2026, 7, 26, 15, 30, tzinfo=UTC)
assert is_in_quiet_hours(
current,
"Asia/Shanghai",
time(22, 0),
time(7, 0),
)
assert next_quiet_end(
current,
"Asia/Shanghai",
time(22, 0),
time(7, 0),
) == datetime(2026, 7, 26, 23, 0)

317
tests/test_v2_hardening.py Normal file
View File

@@ -0,0 +1,317 @@
import json
from pathlib import Path
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.background.task_queue.reports import _queue_celery_report_push
from app.core.config import Settings
from app.core.http.masking import MASKED_VALUE, mask_sensitive
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import AIMemoryStatus
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.service import AIMemoryService
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import Project, ReportPushRun
from app.modules.events.models import DomainEvent
from app.modules.feishu.service import FeishuService
from app.modules.reports.constants import ReportPushStatus
from app.tasks import celery_app
from app.tasks.reports import _push_report
from app.tools import init_db
def _production_settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"app_env": "production",
"database_url": "postgresql+psycopg://app:secret@db/app",
"api_key": "service-key",
"audit_api_key": "audit-key",
"cors_origins": ["https://internal.example.com"],
"debug": False,
"mask_sensitive_responses": True,
"read_only_mode": True,
}
values.update(overrides)
return Settings(_env_file=None, **values)
def test_production_keys_must_be_enabled_isolated_and_safe() -> None:
settings = _production_settings()
assert settings.api_key == "service-key"
with pytest.raises(ValueError, match="API_KEY or API_KEYS"):
_production_settings(
api_key=None,
api_keys=[{"key": "disabled-key", "enabled": False}],
)
with pytest.raises(ValueError, match="cannot overlap"):
_production_settings(audit_api_key="service-key")
with pytest.raises(ValueError, match="MASK_SENSITIVE_RESPONSES"):
_production_settings(mask_sensitive_responses=False)
with pytest.raises(ValueError, match="READ_ONLY_MODE"):
_production_settings(read_only_mode=False)
def test_audit_json_strings_and_responses_mask_sensitive_fields() -> None:
engine = create_engine("sqlite://")
AuditLog.__table__.create(engine)
try:
with Session(engine) as db:
service = AuditService(db)
dict_record = service.log(
AuditLogCreate(
action="security.redaction.dict",
request_payload={
"accessToken": "access-secret",
"nested": {
"clientSecret": "client-secret",
"safe": "visible",
},
},
)
)
json_record = service.log(
AuditLogCreate(
action="security.redaction.json",
request_payload=json.dumps(
{
"authorization": "Bearer secret",
"nested": {
"refreshToken": "refresh-secret",
"safe": "visible",
},
}
),
)
)
dict_stored = json.loads(dict_record.request_payload or "{}")
json_stored = json.loads(json_record.request_payload or "{}")
assert dict_stored["accessToken"] == AUDIT_REDACTED_VALUE
assert dict_stored["nested"]["clientSecret"] == AUDIT_REDACTED_VALUE
assert dict_stored["nested"]["safe"] == "visible"
assert json_stored["authorization"] == AUDIT_REDACTED_VALUE
assert json_stored["nested"]["refreshToken"] == AUDIT_REDACTED_VALUE
assert json_stored["nested"]["safe"] == "visible"
masked = mask_sensitive(
{
"accessToken": "access-secret",
"nested": {
"clientSecret": "client-secret",
"privateKey": "private-secret",
"safe": "visible",
},
}
)
assert masked["accessToken"] == MASKED_VALUE
assert masked["nested"]["clientSecret"] == MASKED_VALUE
assert masked["nested"]["privateKey"] == MASKED_VALUE
assert masked["nested"]["safe"] == "visible"
finally:
engine.dispose()
def test_report_queue_failure_is_persisted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine("sqlite://")
ReportPushRun.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
class BrokenSignature:
def apply_async(self, task_id: str) -> None:
_ = task_id
raise RuntimeError("broker unavailable")
monkeypatch.setattr("app.core.database.SessionLocal", factory)
monkeypatch.setattr(
celery_app,
"signature",
lambda *args, **kwargs: BrokenSignature(),
)
try:
with pytest.raises(RuntimeError, match="broker unavailable"):
_queue_celery_report_push(
task_name="reports.test",
report_type="test",
title="Test report",
receive_id=None,
receive_id_type="chat_id",
actor="pytest",
)
with factory() as db:
run = db.execute(select(ReportPushRun)).scalar_one()
assert run.status == ReportPushStatus.FAILED
assert run.task_id
assert run.error_message == "broker unavailable"
finally:
engine.dispose()
def test_eager_report_terminal_state_is_not_overwritten(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine("sqlite://")
ReportPushRun.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
class EagerSignature:
def __init__(self, push_run_code: str):
self.push_run_code = push_run_code
def apply_async(self, task_id: str) -> None:
with factory() as db:
run = db.execute(
select(ReportPushRun).where(
ReportPushRun.code == self.push_run_code
)
).scalar_one()
assert run.task_id == task_id
run.status = ReportPushStatus.SUCCESS
db.commit()
def eager_signature(*args: object, **kwargs: object) -> EagerSignature:
_ = args
task_kwargs = kwargs["kwargs"]
assert isinstance(task_kwargs, dict)
return EagerSignature(str(task_kwargs["push_run_code"]))
monkeypatch.setattr("app.core.database.SessionLocal", factory)
monkeypatch.setattr(celery_app, "signature", eager_signature)
try:
queued = _queue_celery_report_push(
task_name="reports.test",
report_type="test",
title="Test report",
receive_id=None,
receive_id_type="chat_id",
actor="pytest",
)
with factory() as db:
run = db.execute(select(ReportPushRun)).scalar_one()
assert run.status == ReportPushStatus.SUCCESS
assert run.task_id == queued["task_id"]
finally:
engine.dispose()
def test_successful_report_task_redelivery_does_not_send_twice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_engine("sqlite://")
for table in (AuditLog.__table__, DomainEvent.__table__, ReportPushRun.__table__):
table.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
calls = {"build": 0, "send": 0}
def build_report(service: object, actor: str) -> dict[str, object]:
_ = service, actor
calls["build"] += 1
return {
"title": "Idempotent report",
"report_type": "test",
"lines": ["ok"],
"content": "ok",
}
def fake_send_card(
service: object,
card: dict,
receive_id: str | None,
receive_id_type: str,
actor: str,
) -> dict[str, object]:
_ = service, card, receive_id, receive_id_type, actor
calls["send"] += 1
return {"code": 0, "message_id": "om-idempotent"}
monkeypatch.setattr("app.tasks.reports.SessionLocal", factory)
monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
try:
with factory() as db:
run = ReportPushRun(
code="PUSH-IDEMPOTENT",
report_type="test",
title="Idempotent report",
receive_id="oc-idempotent",
receive_id_type="chat_id",
status=ReportPushStatus.QUEUED,
actor="pytest",
)
db.add(run)
db.commit()
first = _push_report(build_report, "oc-idempotent", "chat_id", "pytest", run.code)
second = _push_report(build_report, "oc-idempotent", "chat_id", "pytest", run.code)
assert first == second == {"code": 0, "message_id": "om-idempotent"}
assert calls == {"build": 1, "send": 1}
with factory() as db:
stored = db.execute(select(ReportPushRun)).scalar_one()
assert stored.status == ReportPushStatus.SUCCESS
assert stored.sent_at is not None
finally:
engine.dispose()
def test_init_db_upgrades_to_alembic_head(
monkeypatch: pytest.MonkeyPatch,
) -> None:
called: dict[str, object] = {}
def fake_upgrade(config: object, revision: str) -> None:
called["config"] = config
called["revision"] = revision
monkeypatch.setattr(init_db.command, "upgrade", fake_upgrade)
init_db.main()
config = called["config"]
assert called["revision"] == "head"
assert Path(config.config_file_name).name == "alembic.ini"
assert Path(config.get_main_option("script_location")).name == "alembic"
def test_memory_retention_does_not_commit_caller_transaction() -> None:
engine = create_engine("sqlite://")
AIMemoryEntry.__table__.create(engine)
Project.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
with factory() as db:
expired = AIMemoryEntry(
code="MEM-EXPIRED",
scope="project",
subject="P-ROLLBACK",
content="expired",
status=AIMemoryStatus.ACTIVE,
expires_at=utc_now(),
)
db.add(expired)
db.commit()
db.add(Project(code="P-ROLLBACK", name="Must roll back"))
assert AIMemoryService(db).list_entries(scope="project") == []
db.rollback()
with factory() as db:
assert db.execute(
select(Project).where(Project.code == "P-ROLLBACK")
).scalar_one_or_none() is None
stored = db.execute(
select(AIMemoryEntry).where(AIMemoryEntry.code == "MEM-EXPIRED")
).scalar_one()
assert stored.status == AIMemoryStatus.ACTIVE
finally:
engine.dispose()

View File

@@ -0,0 +1,243 @@
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

199
tests/test_v3_remaining.py Normal file
View File

@@ -0,0 +1,199 @@
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import sessionmaker
from app.application.events import EventDispatchService
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import (
AIMemoryPayloadKey,
AIMemoryStatus,
)
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.service import AIMemoryService
from app.modules.business.constants import StatusValue
from app.modules.business.models import FundAccount, PerformanceMetric, Project
from app.modules.events.constants import (
EventAggregateType,
EventSource,
EventStatus,
EventType,
)
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
from app.modules.reports.constants import EnterpriseAnalyticsKey, MetricKey
from app.modules.reports.services import ReportService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
def _session_factory():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine, expire_on_commit=False)
def test_workflow_actions_are_idempotent_per_source_event() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
event = EventService(db).emit(
event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED,
source=EventSource.ANALYTICS,
aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS,
aggregate_id="snapshot-1",
idempotency_key="analytics:snapshot-1",
)
first = EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-first",
)
assert first.status == EventStatus.PROCESSED
first.status = EventStatus.PENDING
first.processed_at = None
first.next_attempt_at = utc_now()
db.commit()
second = EventDispatchService(db).dispatch_event(
event.event_id,
worker_id="pytest-second",
)
assert second.status == EventStatus.PROCESSED
assert db.scalar(select(func.count()).select_from(WorkflowInstance)) == 1
assert db.scalar(select(func.count()).select_from(WorkflowAction)) == 1
action = db.execute(select(WorkflowAction)).scalar_one()
assert action.source_event_id == event.event_id
finally:
engine.dispose()
def test_ai_memory_has_no_unrelated_fallback_and_archives_expired_entries() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
service = AIMemoryService(db)
first = service.auto_write(
prompt="Remember concise operational summaries",
context={
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-IDEMPOTENT",
},
answer="Use concise bullet summaries for project updates.",
)
duplicate = service.auto_write(
prompt="Remember concise operational summaries",
context={
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-IDEMPOTENT",
},
answer="Use concise bullet summaries for project updates.",
)
assert first is not None
assert duplicate is not None
assert duplicate.code == first.code
assert db.scalar(select(func.count()).select_from(AIMemoryEntry)) == 1
assert (
service.recall(
query="no-such-memory-token",
scope="project",
subject="P-MEM-IDEMPOTENT",
)
== []
)
first.expires_at = utc_now() - timedelta(seconds=1)
db.commit()
assert (
service.list_entries(
scope="project",
subject="P-MEM-IDEMPOTENT",
status_filter=AIMemoryStatus.ACTIVE,
)
== []
)
db.refresh(first)
assert first.status == AIMemoryStatus.ARCHIVED
finally:
engine.dispose()
def test_enterprise_analytics_validates_scope_and_reuses_identical_snapshot() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
db.add_all(
[
Project(
code="P-SCOPED",
name="Scoped project",
owner="owner-a",
status=StatusValue.RUNNING_CN,
),
PerformanceMetric(
code="PERF-GLOBAL",
name="Global metric",
weight=10,
auto_score=80,
confirmed_score=75,
status=StatusValue.REVIEWED,
),
FundAccount(
code="FUND-GLOBAL",
name="Global account",
current_balance=1000,
),
]
)
db.commit()
service = ReportService(db)
with pytest.raises(HTTPException) as exc_info:
service.enterprise_analytics(
period_start=date(2030, 2, 2),
period_end=date(2030, 2, 1),
)
assert exc_info.value.status_code == 422
first = service.enterprise_analytics(project_code="P-SCOPED")
second = service.enterprise_analytics(project_code="P-SCOPED")
assert first[EnterpriseAnalyticsKey.CODE] == second[EnterpriseAnalyticsKey.CODE]
assert first[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.TOTAL] == 0
assert (
first[EnterpriseAnalyticsKey.FINANCE][
MetricKey.CURRENT_BALANCE_TOTAL
]
== 0
)
events = list(
db.execute(
select(DomainEvent).where(
DomainEvent.event_type
== EventType.ENTERPRISE_ANALYTICS_GENERATED
)
).scalars()
)
assert len(events) == 1
assert events[0].aggregate_id == first[EnterpriseAnalyticsKey.CODE]
dispatched = EventDispatchService(db).dispatch_event(
events[0].event_id,
worker_id="pytest-enterprise",
)
assert dispatched.status == EventStatus.PROCESSED
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type
== WorkflowType.ENTERPRISE_ANALYTICS
)
).scalar_one()
assert workflow.status == WorkflowStatus.COMPLETED
assert workflow.aggregate_id == first[EnterpriseAnalyticsKey.CODE]
finally:
engine.dispose()