feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
This commit is contained in:
2026-07-27 17:14:37 +08:00
parent d7db84571d
commit eb8267ed18
61 changed files with 8703 additions and 183 deletions

View File

@@ -38,6 +38,7 @@ os.environ.update(
"FEISHU_ENCRYPT_KEY": "",
"FEISHU_DEFAULT_CHAT_ID": "",
"FEISHU_VERIFICATION_TOKEN": "test-feishu-token",
"FEISHU_EVENT_TRANSPORT": "webhook",
"FEISHU_ADMIN_IDENTITIES": "",
"FEISHU_USER_FEATURES_ENABLED": "false",
"MARKET_DATA_TOKEN": "",

View File

@@ -10,7 +10,7 @@ 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.application.feishu.events import FeishuEventService, _identifier_digest
from app.core.config import get_settings
from app.core.database import Base
from app.modules.audit.models import AuditLog
@@ -18,6 +18,7 @@ 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
from app.modules.feishu.services import FeishuInboundService
@pytest.fixture
@@ -26,6 +27,7 @@ def session_factory(
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
monkeypatch.setenv("FEISHU_EVENT_TRANSPORT", "long_connection")
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
get_settings.cache_clear()
@@ -135,6 +137,9 @@ def test_verified_ticket_is_deduplicated_rotated_and_never_leaked(
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
receipts = list(db.execute(select(FeishuEventReceipt)).scalars())
assert all(item.status == "succeeded" for item in receipts)
assert all(item.payload is None for item in receipts)
assert rotated_ticket not in json.dumps(rotated, ensure_ascii=False)
audits = list(db.execute(select(AuditLog)).scalars())
@@ -196,6 +201,59 @@ def test_only_verified_matching_app_ticket_events_can_write(
assert db.scalar(select(FeishuAppTicket)) is None
def test_app_ticket_write_and_receipt_success_are_atomic(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = _v2_ticket_event("atomic-app-ticket", "atomic-ticket-secret")
original_mark_success = FeishuInboundService._mark_success
fail_before_commit = True
def mark_success(
atomic_db: Session,
event_key: str,
lock_owner: str,
current: Any,
) -> None:
nonlocal fail_before_commit
if fail_before_commit:
fail_before_commit = False
raise RuntimeError("simulated crash before atomic app-ticket commit")
original_mark_success(atomic_db, event_key, lock_owner, current)
monkeypatch.setattr(
FeishuInboundService,
"_mark_success",
staticmethod(mark_success),
)
with session_factory() as db:
service = FeishuEventService(db)
with pytest.raises(HTTPException) as first_error:
service._handle_verified_event(
payload,
source=FeishuEventSource.WEBHOOK,
)
assert first_error.value.status_code == 503
assert db.scalar(select(FeishuAppTicket)) is None
receipt = db.scalar(select(FeishuEventReceipt))
assert receipt is not None
assert receipt.status == "retry"
result = service._handle_verified_event(
payload,
source=FeishuEventSource.WEBHOOK,
)
assert result == {"ok": True, "handled": True}
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == (
"atomic-ticket-secret"
)
db.refresh(receipt)
assert receipt.status == "succeeded"
assert receipt.attempt_count == 2
def test_v1_app_ticket_payload_uses_uuid_receipt(
session_factory: sessionmaker[Session],
) -> None:
@@ -209,8 +267,18 @@ def test_v1_app_ticket_payload_uses_uuid_receipt(
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 receipt.event_id == _identifier_digest(
"v1-ticket-uuid",
"event-id",
)
assert receipt.event_key == _identifier_digest(
"cli-ticket-app:app_ticket:v1-ticket-uuid",
"event-key",
)
assert "v1-ticket-uuid" not in receipt.event_id
assert "cli-ticket-app" not in receipt.event_key
assert receipt.status == "succeeded"
assert receipt.payload is None
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == ticket
assert ticket not in json.dumps(result, ensure_ascii=False)
@@ -257,6 +325,7 @@ def test_long_connection_registers_custom_app_ticket_handler(
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_EVENT_TRANSPORT", "long_connection")
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
get_settings.cache_clear()
try:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
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.core.database.migrations import expected_alembic_heads
from app.modules.feishu import long_connection
from app.modules.observability.constants import (
HeartbeatComponent,
HeartbeatStatus,
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.routes import router as observability_router
from app.modules.observability.service import ObservabilityService
def test_connection_heartbeat_tracks_disconnect_and_recovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = SimpleNamespace(_conn=None)
statuses: list[str] = []
class FakeStopEvent:
def __init__(self) -> None:
self.wait_count = 0
def is_set(self) -> bool:
return self.wait_count >= 3
def wait(self, timeout: float) -> None:
assert timeout == 1
self.wait_count += 1
if self.wait_count == 1:
client._conn = SimpleNamespace(
state=SimpleNamespace(name="OPEN"),
)
elif self.wait_count == 2:
client._conn = SimpleNamespace(
state=SimpleNamespace(name="CLOSED"),
)
monkeypatch.setattr(
long_connection,
"_record_runtime_heartbeat",
lambda _instance_id, status_value: statuses.append(status_value),
)
long_connection._heartbeat_loop(
FakeStopEvent(), # type: ignore[arg-type]
"feishu-events-test",
30,
client,
)
assert statuses == [
HeartbeatStatus.DEGRADED,
HeartbeatStatus.OK,
HeartbeatStatus.DEGRADED,
]
def test_connection_adapter_supports_sdk_state_variants() -> None:
assert long_connection._sdk_connection_is_open(SimpleNamespace(_conn=None)) is False
unknown_client = SimpleNamespace(_conn=SimpleNamespace())
assert long_connection._sdk_connection_is_open(unknown_client) is False
assert (
long_connection._connection_heartbeat_status(unknown_client)
== HeartbeatStatus.DEGRADED
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(_conn=SimpleNamespace(closed=False))
)
is True
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(_conn=SimpleNamespace(open=False))
)
is False
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(
_conn=SimpleNamespace(state=SimpleNamespace(name="OPEN"))
)
)
is True
)
assert (
long_connection._sdk_connection_is_open(
SimpleNamespace(
_conn=SimpleNamespace(state=SimpleNamespace(name="CLOSING"))
)
)
is False
)
def test_readiness_returns_503_while_disconnected_and_recovers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("FEISHU_EVENT_TRANSPORT", "long_connection")
monkeypatch.setenv("FEISHU_APP_ID", "app-id")
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(
text("create table alembic_version (version_num varchar(32))")
)
connection.execute(
text("insert into alembic_version (version_num) values (:revision)"),
{"revision": expected_alembic_heads()[0]},
)
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
app = FastAPI()
app.include_router(observability_router, prefix="/api/v1")
def override_get_db() -> Any:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
try:
with Session(engine) as db:
ObservabilityService(db).record_heartbeat(
HeartbeatComponent.FEISHU_EVENTS,
"feishu-events-test",
status_value=HeartbeatStatus.DEGRADED,
)
disconnected = client.get("/api/v1/health/ready")
assert disconnected.status_code == 503
disconnected_check = disconnected.json()[ObservabilityKey.CHECKS][
ObservabilityKey.FEISHU_EVENTS
]
assert (
disconnected_check[ObservabilityKey.STATUS]
== ObservabilityStatus.DEGRADED
)
assert disconnected_check["reason"] == "heartbeat_degraded"
with Session(engine) as db:
ObservabilityService(db).record_heartbeat(
HeartbeatComponent.FEISHU_EVENTS,
"feishu-events-test",
status_value=HeartbeatStatus.OK,
)
recovered = client.get("/api/v1/health/ready")
assert recovered.status_code == 200
recovered_check = recovered.json()[ObservabilityKey.CHECKS][
ObservabilityKey.FEISHU_EVENTS
]
assert recovered_check[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert recovered_check["reason"] is None
finally:
client.close()
engine.dispose()
get_settings.cache_clear()
@pytest.mark.parametrize(
("configured_identities", "expected_reason"),
[
([""], "admin_identities_missing"),
(["not-an-identity"], "admin_identities_invalid"),
],
)
def test_production_readiness_parses_admin_identities_without_exposing_them(
monkeypatch: pytest.MonkeyPatch,
configured_identities: list[str],
expected_reason: str,
) -> None:
sensitive_value = configured_identities[0]
settings = SimpleNamespace(
app_env="production",
feishu_user_features_enabled=True,
feishu_admin_identities=configured_identities,
feishu_event_transport="long_connection",
feishu_app_id="app-id",
feishu_app_secret="app-secret",
feishu_app_type="self",
feishu_default_tenant_key=None,
)
monkeypatch.setattr(
"app.modules.observability.service.get_settings",
lambda: settings,
)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
result = ObservabilityService(db)._feishu_subscriptions_check()
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert expected_reason in result["reasons"]
if sensitive_value:
assert sensitive_value not in str(result)
finally:
engine.dispose()

View File

@@ -107,7 +107,7 @@ def test_personalization_migration_preserves_and_classifies_legacy_data(
{"created_at": timestamp, "updated_at": timestamp},
)
command.upgrade(config, "202607260005")
command.upgrade(config, "head")
inspector = inspect(engine)
expected_tables = {

View File

@@ -1,3 +1,4 @@
import json
from datetime import datetime
from uuid import uuid4
@@ -5,9 +6,12 @@ import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.application.feishu.commands import FeishuCommandService
from app.application.feishu.handlers.subscriptions import handle_subscription_command
from app.core.config import get_settings
from app.core.database import Base
from app.modules.feishu.constants import FeishuCommandName
from app.modules.ai_agent.service import AIService
from app.modules.feishu.constants import FeishuCommandKey, FeishuCommandName
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import FeishuUser
@@ -91,6 +95,131 @@ def test_create_private_subscription_replies_with_normalized_plan() -> None:
engine.dispose()
def test_rich_text_event_routes_subscription_instead_of_fallback_ai(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="rich-text-command")
principal = FeishuPrincipal.from_user(
user,
chat_id="private-chat",
chat_type="p2p",
)
text = "订阅 每隔 15 分钟:给我一句简短的工作提醒"
payload = {
"header": {"tenant_key": user.tenant_key},
"event": {
"message": {
"chat_id": "private-chat",
"chat_type": "p2p",
"content": json.dumps(
{
"content": [
[
{
"tag": "text",
"text": text,
"style": [],
}
]
]
},
ensure_ascii=False,
),
},
"sender": {"sender_id": {"open_id": user.open_id}},
},
}
commands = FeishuCommandService(db)
extracted = commands.extract_event_command(payload)
assert extracted is not None
assert extracted[FeishuCommandKey.TEXT] == text
result = commands.handle_text(
extracted[FeishuCommandKey.TEXT],
chat_id="private-chat",
principal=principal,
auto_reply=False,
)
subscription = db.scalar(select(PushSubscription))
assert result["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
assert "计划:每隔 15 分钟" in result["content"]
assert subscription is not None
assert subscription.prompt == "给我一句简短的工作提醒"
finally:
get_settings.cache_clear()
engine.dispose()
def test_rich_text_event_routes_help_instead_of_fallback_ai(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
monkeypatch.setattr(
AIService,
"ask_personalized",
lambda *args, **kwargs: pytest.fail("Rich-text help command reached AI fallback"),
)
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
user = _user(db, suffix="rich-text-help")
principal = FeishuPrincipal.from_user(
user,
chat_id="private-chat",
chat_type="p2p",
)
payload = {
"header": {"tenant_key": user.tenant_key},
"event": {
"message": {
"chat_id": "private-chat",
"chat_type": "p2p",
"content": json.dumps(
{
"content": [
[
{
"tag": "text",
"text": "帮助",
"style": [],
}
]
]
},
ensure_ascii=False,
),
},
"sender": {"sender_id": {"open_id": user.open_id}},
},
}
commands = FeishuCommandService(db)
extracted = commands.extract_event_command(payload)
assert extracted is not None
assert extracted[FeishuCommandKey.TEXT] == "帮助"
result = commands.handle_text(
extracted[FeishuCommandKey.TEXT],
chat_id="private-chat",
principal=principal,
auto_reply=False,
)
assert result["command"] == FeishuCommandName.HELP
assert "你可以使用:" in result["content"]
finally:
get_settings.cache_clear()
engine.dispose()
def test_group_subscription_uses_current_verified_chat_and_requires_admin() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

View File

@@ -25,7 +25,8 @@ from app.modules.subscriptions.models import PushDelivery, PushSubscription
@pytest.fixture(autouse=True)
def _reset_settings() -> None:
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@@ -248,7 +249,11 @@ def test_readiness_checks_credentials_for_processable_deliveries_without_active_
engine.dispose()
def test_readiness_ignores_terminal_deliveries_without_active_plan() -> None:
def test_readiness_ignores_terminal_deliveries_without_active_plan(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:

View File

@@ -21,6 +21,7 @@ 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.identifiers import feishu_audit_identity_hash
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
@@ -175,6 +176,26 @@ def _seed_personal_data(db: Session, user: FeishuUser) -> None:
request_id=f"request-{user.code}",
)
)
db.add(
AuditLog(
actor=feishu_audit_identity_hash(
user.tenant_key,
user.open_id,
),
source="feishu",
action="webhook_event",
target_type="webhook",
target_id="sha256-event-evidence",
request_payload=json.dumps(
{
"tenant_key": "sha256-tenant-evidence",
"chat_id": "sha256-chat-evidence",
}
),
response_payload=json.dumps({"accepted": True}),
request_id=f"accepted-{user.code}",
)
)
db.commit()
@@ -224,6 +245,10 @@ def test_confirm_erases_all_personal_data_and_anonymizes_audit(
tenant_key = user.tenant_key
open_id = user.open_id
identifiers = (user.code, user.open_id, user.union_id, user.user_id)
accepted_actor = feishu_audit_identity_hash(
user.tenant_key,
user.open_id,
)
principal = FeishuPrincipal.from_user(user)
service = FeishuPersonalDataService(db)
@@ -270,6 +295,7 @@ def test_confirm_erases_all_personal_data_and_anonymizes_audit(
for log in logs
)
assert all(identifier not in serialized_logs for identifier in identifiers)
assert accepted_actor not in serialized_logs
final_log = db.execute(
select(AuditLog).where(AuditLog.action == PERSONAL_DATA_ERASURE_ACTION)
).scalar_one()
@@ -294,6 +320,18 @@ def test_confirm_erases_all_personal_data_and_anonymizes_audit(
assert anonymized_history.request_id is None
assert anonymized_history.status == "success"
anonymized_acceptance = db.execute(
select(AuditLog).where(
AuditLog.action == "webhook_event",
AuditLog.actor == result.anonymous_id,
)
).scalar_one()
assert anonymized_acceptance.target_type is None
assert anonymized_acceptance.target_id is None
assert anonymized_acceptance.request_payload is None
assert anonymized_acceptance.response_payload is None
assert anonymized_acceptance.request_id is None
recreated = FeishuIdentityService(db).resolve_or_register(
tenant_key=tenant_key,
open_id=open_id,

View File

@@ -0,0 +1,608 @@
import shutil
import subprocess
from datetime import timedelta
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.scheduling import create_scheduler
from app.core.config import Settings, get_settings
from app.core.database import Base
from app.core.database.safety import validate_platform_migration_target
from app.core.utils.time import utc_now
from app.modules.events.constants import EventStatus
from app.modules.events.models import DomainEvent
from app.modules.feishu.constants import FeishuInboundStatus
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.observability.constants import (
HeartbeatComponent,
ObservabilityKey,
ObservabilityStatus,
)
from app.modules.observability.models import SystemHeartbeat
from app.modules.observability import runtime as observability_runtime
from app.modules.observability.service import ObservabilityService
from app.modules.workflows.constants import WorkflowStatus
from app.modules.workflows.models import WorkflowInstance
from app.tools import run_scheduler, runtime_preflight
def _production_settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"app_env": "production",
"database_url": (
"postgresql+psycopg://app:runtime-database-password-2026@db/app"
),
"api_key": "runtime-service-key-2026-primary",
"audit_api_key": "runtime-audit-key-2026-independent",
"cors_origins": ["https://internal.example.com"],
"debug": False,
"mask_sensitive_responses": True,
"read_only_mode": True,
"feishu_app_id": None,
"feishu_app_secret": None,
"feishu_event_transport": "disabled",
"feishu_verification_token": None,
"feishu_user_features_enabled": False,
}
values.update(overrides)
return Settings(_env_file=None, **values)
def _stamp_alembic_head(engine: Engine) -> None:
expected = runtime_preflight.expected_alembic_heads()
assert len(expected) == 1
with engine.begin() as connection:
connection.execute(
text("create table alembic_version (version_num varchar(32))")
)
connection.execute(
text("insert into alembic_version (version_num) values (:revision)"),
{"revision": expected[0]},
)
def test_production_feishu_transport_fails_closed() -> None:
with pytest.raises(ValueError, match="FEISHU_EVENT_TRANSPORT"):
_production_settings(
feishu_user_features_enabled=True,
feishu_admin_identities=["tenant:open-id"],
)
with pytest.raises(ValueError, match="FEISHU_VERIFICATION_TOKEN"):
_production_settings(
feishu_event_transport="webhook",
feishu_app_id="app-id",
feishu_app_secret="feishu-app-secret-2026-secure-value",
)
with pytest.raises(ValueError, match="FEISHU_APP_ID"):
_production_settings(feishu_event_transport="long_connection")
settings = _production_settings(
feishu_event_transport="long_connection",
feishu_app_id="app-id",
feishu_app_secret="feishu-app-secret-2026-secure-value",
feishu_user_features_enabled=True,
feishu_admin_identities=["tenant:open-id"],
)
assert settings.feishu_event_transport == "long_connection"
def test_production_database_target_fails_closed() -> None:
with pytest.raises(ValueError, match="must use PostgreSQL"):
_production_settings(
database_url=(
"mysql+pymysql://app:runtime-database-password-2026@legacy/business"
),
)
with pytest.raises(ValueError, match="must target different databases"):
_production_settings(
database_url=(
"postgresql+psycopg://platform:platform-password-2026-secure@db/platform"
),
legacy_database_url=(
"postgresql://readonly:readonly-password-2026-secure@db:5432/platform"
),
)
settings = _production_settings(
database_url=(
"postgresql+psycopg://platform:platform-password-2026-secure@db/platform"
),
legacy_database_url=(
"mysql+pymysql://readonly:readonly-password-2026-secure@legacy/business"
),
)
assert settings.database_url.startswith("postgresql")
def test_production_placeholders_and_invalid_admin_identities_fail_closed() -> None:
placeholder_key = "replace-with-a-random-service-key"
with pytest.raises(ValueError, match="API_KEY/API_KEYS") as error:
_production_settings(api_key=placeholder_key)
assert placeholder_key not in str(error.value)
with pytest.raises(ValueError, match="DATABASE_URL password"):
_production_settings(
database_url="postgresql+psycopg://app:change-me@db/app",
)
with pytest.raises(ValueError, match="tenant_key:open_id"):
_production_settings(
feishu_event_transport="long_connection",
feishu_app_id="app-id",
feishu_app_secret="feishu-app-secret-2026-secure-value",
feishu_user_features_enabled=True,
feishu_admin_identities=["not-a-valid-identity"],
)
def test_platform_migration_target_never_accepts_mysql_or_legacy_database() -> None:
with pytest.raises(RuntimeError, match="PostgreSQL or local SQLite"):
validate_platform_migration_target(
"mysql+pymysql://platform:secret@legacy/business",
None,
)
with pytest.raises(RuntimeError, match="matches LEGACY_DATABASE_URL"):
validate_platform_migration_target(
"postgresql+psycopg://platform:one@db/platform",
"postgresql://readonly:two@db:5432/platform",
)
def test_environment_overrides_dotenv(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
base = tmp_path / ".env"
base.write_text("SCHEDULER_ENABLED=true\n", encoding="utf-8")
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
assert Settings(_env_file=base).scheduler_enabled is True
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
assert Settings(_env_file=base).scheduler_enabled is False
def test_scheduler_process_refuses_disabled_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
run_scheduler,
"get_settings",
lambda: SimpleNamespace(scheduler_enabled=False),
)
monkeypatch.setattr(
run_scheduler,
"create_scheduler",
lambda: pytest.fail("disabled scheduler must not be created"),
)
with pytest.raises(RuntimeError, match="SCHEDULER_ENABLED"):
run_scheduler.main()
def test_runtime_preflight_requires_exact_alembic_head(tmp_path: Path) -> None:
database_path = tmp_path / "runtime-preflight.db"
database_url = f"sqlite:///{database_path.as_posix()}"
settings = Settings(
_env_file=None,
database_url=database_url,
feishu_event_transport="disabled",
)
expected = runtime_preflight.expected_alembic_heads()
assert len(expected) == 1
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text("create table alembic_version (version_num varchar(32))")
)
connection.execute(
text("insert into alembic_version (version_num) values ('old')")
)
with pytest.raises(
runtime_preflight.RuntimePreflightError,
match="current Alembic head",
):
runtime_preflight.run_preflight(settings)
with engine.begin() as connection:
connection.execute(text("delete from alembic_version"))
connection.execute(
text(
"insert into alembic_version (version_num) values (:revision)"
),
{"revision": expected[0]},
)
result = runtime_preflight.run_preflight(settings)
assert result.revisions == expected
finally:
engine.dispose()
def test_scheduler_registers_immediate_runtime_heartbeats(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
get_settings.cache_clear()
try:
scheduler = create_scheduler()
assert scheduler.get_job("scheduler_heartbeat").next_run_time is not None
assert scheduler.get_job("worker_heartbeat").next_run_time is not None
assert scheduler.get_job("event_dispatch").next_run_time is not None
assert scheduler.get_job("feishu_inbound_event_cycle") is not None
finally:
get_settings.cache_clear()
def test_required_component_heartbeats_control_readiness(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
monkeypatch.setenv("FEISHU_ADMIN_IDENTITIES", "")
monkeypatch.setenv("FEISHU_EVENT_TRANSPORT", "long_connection")
monkeypatch.setenv("FEISHU_APP_ID", "app-id")
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret")
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
get_settings.cache_clear()
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
_stamp_alembic_head(engine)
try:
with Session(engine) as db:
service = ObservabilityService(db)
missing = service.ready()
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.SCHEDULER][
ObservabilityKey.STATUS
] == ObservabilityStatus.DEGRADED
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.FEISHU_EVENTS][
ObservabilityKey.STATUS
] == ObservabilityStatus.DEGRADED
service.record_heartbeat(
HeartbeatComponent.SCHEDULER,
"scheduler-test",
)
service.record_heartbeat(
HeartbeatComponent.FEISHU_EVENTS,
"feishu-events-test",
)
ready = service.ready()
assert ready[ObservabilityKey.STATUS] == ObservabilityStatus.OK
heartbeat = db.query(SystemHeartbeat).filter_by(
component=HeartbeatComponent.SCHEDULER,
).one()
heartbeat.last_seen_at = utc_now() - timedelta(minutes=10)
db.commit()
stale = service.ready()
assert stale[ObservabilityKey.CHECKS][ObservabilityKey.SCHEDULER][
ObservabilityKey.STATUS
] == ObservabilityStatus.DEGRADED
finally:
get_settings.cache_clear()
engine.dispose()
def test_api_lifecycle_records_immediate_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
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)
monkeypatch.setattr(observability_runtime, "SessionLocal", factory)
monkeypatch.setattr(
observability_runtime,
"get_settings",
lambda: SimpleNamespace(heartbeat_interval_seconds=3600),
)
app = FastAPI()
observability_runtime.attach_api_heartbeat(app)
try:
with TestClient(app):
thread = app.state.api_heartbeat_thread
assert thread.is_alive()
with Session(engine) as db:
heartbeat = db.query(SystemHeartbeat).filter_by(
component=HeartbeatComponent.API,
instance_id=app.state.api_heartbeat_instance_id,
).one()
assert heartbeat.status == ObservabilityStatus.OK
assert not thread.is_alive()
finally:
engine.dispose()
def test_production_readiness_requires_api_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = SimpleNamespace(
app_env="production",
scheduler_enabled=False,
task_queue_enabled=False,
feishu_user_features_enabled=False,
feishu_event_transport="disabled",
event_dispatch_enabled=True,
heartbeat_interval_seconds=60,
heartbeat_retention_seconds=86400,
)
monkeypatch.setattr(
"app.modules.observability.service.get_settings",
lambda: settings,
)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
_stamp_alembic_head(engine)
try:
with Session(engine) as db:
service = ObservabilityService(db)
service.record_heartbeat(
HeartbeatComponent.SCHEDULER,
"scheduler-test",
)
missing = service.ready()
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.API][
ObservabilityKey.STATUS
] == ObservabilityStatus.DEGRADED
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.API][
"reason"
] == "heartbeat_missing"
service.record_heartbeat(
HeartbeatComponent.API,
"api-test",
)
ready = service.ready()
assert ready[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert ready[ObservabilityKey.CHECKS][ObservabilityKey.API][
ObservabilityKey.STATUS
] == ObservabilityStatus.OK
finally:
engine.dispose()
def test_production_schema_readiness_requires_alembic_head(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"app.modules.observability.service.get_settings",
lambda: SimpleNamespace(app_env="production"),
)
engine = create_engine("sqlite://")
try:
with Session(engine) as db:
db.execute(text("create table alembic_version (version_num varchar(32))"))
db.execute(
text("insert into alembic_version (version_num) values ('old-revision')")
)
db.commit()
service = ObservabilityService(db)
mismatch = service._schema_check()
assert mismatch[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
expected = mismatch["expected"]
assert len(expected) == 1
db.execute(text("delete from alembic_version"))
db.execute(
text("insert into alembic_version (version_num) values (:revision)"),
{"revision": expected[0]},
)
db.commit()
assert service._schema_check()[ObservabilityKey.STATUS] == ObservabilityStatus.OK
finally:
engine.dispose()
def test_historical_terminal_failures_do_not_block_readiness() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
db.add(
DomainEvent(
event_id="terminal-event",
event_type="test.terminal",
aggregate_type="test",
status=EventStatus.FAILED,
max_attempts=1,
)
)
db.add(
WorkflowInstance(
code="WF-TERMINAL",
workflow_type="test",
aggregate_type="test",
status=WorkflowStatus.FAILED,
)
)
db.commit()
service = ObservabilityService(db)
events = service._events_check()
workflows = service._workflows_check()
assert events[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert events["failed"] == 1
assert workflows[ObservabilityKey.STATUS] == ObservabilityStatus.OK
assert workflows["failed"] == 1
finally:
engine.dispose()
def test_feishu_inbound_metrics_expose_counts_without_payloads() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
try:
with Session(engine) as db:
for index, status_value in enumerate(
(
FeishuInboundStatus.PENDING,
FeishuInboundStatus.RETRY,
FeishuInboundStatus.PROCESSING,
FeishuInboundStatus.FAILED,
)
):
db.add(
FeishuEventReceipt(
event_key=f"metric-event-{index}",
source="webhook",
status=status_value,
payload={"text": "must not appear in metrics"},
)
)
db.commit()
metrics = ObservabilityService(db)._feishu_inbound_metrics()
assert metrics == {
"pending": 1,
"retry": 1,
"processing": 1,
"failed": 1,
"reply_pending": 0,
"reply_retry": 0,
"reply_processing": 0,
"reply_failed": 0,
}
assert "payload" not in metrics
assert "must not appear" not in str(metrics)
finally:
engine.dispose()
def test_runtime_deployment_uses_single_dotenv_file() -> None:
compose = Path("docker-compose.yml").read_text(encoding="utf-8")
external_compose = Path("docker-compose.external-db.yml").read_text(
encoding="utf-8"
)
gitignore = Path(".gitignore").read_text(encoding="utf-8")
start_script = Path("scripts/start_runtime.ps1").read_text(encoding="utf-8")
assert "feishu-events:" in compose
assert 'profiles: ["long-connection"]' in compose
assert "/api/v1/health/ready" in compose
assert "/api/v1/health/live" not in compose
assert "${COMPOSE_DATABASE_URL:-postgresql+psycopg://" in compose
assert "@db:5432/" in compose
assert "${DATABASE_URL:" not in compose
assert compose.count("APP_ENV: production") == 5
assert 'profiles: ["internal-database"]' in external_compose
assert external_compose.count("depends_on: !override") == 5
assert external_compose.count(
"${COMPOSE_DATABASE_URL:?COMPOSE_DATABASE_URL is required "
"for external PostgreSQL mode}"
) == 5
assert "${POSTGRES_PASSWORD" not in external_compose
assert compose.count("restart: unless-stopped") >= 6
assert compose.count("path: .env") == 1
assert compose.count("required: true") == 1
assert ".env.runtime" not in compose
assert ".env" in gitignore.splitlines()
assert ".env.runtime" not in gitignore.splitlines()
assert "-WindowStyle Hidden" in start_script
assert "TASK_QUEUE_ENABLED = \"false\"" in start_script
assert "app.tools.runtime_preflight" in start_script
assert "/api/v1/health/ready" in start_script
def test_default_and_external_compose_configs_validate_without_project_dotenv(
tmp_path: Path,
) -> None:
docker = shutil.which("docker")
if docker is None:
pytest.skip("Docker CLI is not installed")
base_compose = tmp_path / "docker-compose.yml"
external_compose = tmp_path / "docker-compose.external-db.yml"
shutil.copyfile("docker-compose.yml", base_compose)
shutil.copyfile("docker-compose.external-db.yml", external_compose)
(tmp_path / ".env").write_text(
"\n".join(
(
"API_KEY=config-test-service-key",
"AUDIT_API_KEY=config-test-audit-key",
"CORS_ORIGINS=[]",
)
)
+ "\n",
encoding="utf-8",
)
default_interpolation = tmp_path / "default.compose.env"
default_interpolation.write_text(
"POSTGRES_PASSWORD=config-test-database-password\n",
encoding="utf-8",
)
default_command = [
docker,
"compose",
"--project-directory",
str(tmp_path),
"--env-file",
str(default_interpolation),
"-f",
str(base_compose),
]
_run_compose_config([*default_command, "config", "--quiet"], tmp_path)
default_services = _run_compose_config(
[*default_command, "config", "--services"],
tmp_path,
)
assert "db" in default_services.splitlines()
external_interpolation = tmp_path / "external.compose.env"
external_interpolation.write_text(
(
"COMPOSE_DATABASE_URL="
"postgresql+psycopg://config-user:config-password@"
"external.invalid:5432/platform\n"
),
encoding="utf-8",
)
external_command = [
docker,
"compose",
"--project-directory",
str(tmp_path),
"--env-file",
str(external_interpolation),
"-f",
str(base_compose),
"-f",
str(external_compose),
]
_run_compose_config([*external_command, "config", "--quiet"], tmp_path)
external_services = _run_compose_config(
[*external_command, "config", "--services"],
tmp_path,
)
assert "db" not in external_services.splitlines()
def _run_compose_config(command: list[str], cwd: Path) -> str:
result = subprocess.run(
command,
cwd=cwd,
check=False,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
return result.stdout

View File

@@ -0,0 +1,626 @@
import json
from pathlib import Path
from types import SimpleNamespace
from alembic import command
from alembic.config import Config
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy.schema import CreateTable
from app.core.config import get_settings
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.business.models import MarketWatchlist
from app.tools import reconcile_platform_schema as schema_tool
def _database(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
name: str,
revision: str,
):
database_path = tmp_path / f"{name}.db"
database_url = f"sqlite:///{database_path.as_posix()}"
monkeypatch.setenv("DATABASE_URL", database_url)
get_settings.cache_clear()
config = Config("alembic.ini")
command.upgrade(config, revision)
return create_engine(database_url), config
def _remove_revision(engine) -> None:
with engine.begin() as connection:
connection.execute(text("DELETE FROM alembic_version"))
def test_remote_drift_allowlist_snapshot_is_explicit() -> None:
operation_counts: dict[str, int] = {}
for item in schema_tool.ALLOWED_DRIFT:
operation_counts[item.operation] = operation_counts.get(item.operation, 0) + 1
assert operation_counts == {
"add_column": 61,
"add_constraint": 2,
"add_fk": 2,
"add_index": 47,
"add_table": 2,
"modify_default": 8,
"modify_nullable": 1,
"remove_constraint": 3,
"remove_index": 9,
"remove_table": 1,
}
clean_005_only = {
schema_tool.DriftKey(
"remove_constraint",
"ai_memory_entries",
"uq_ai_memory_owner_fingerprint",
),
schema_tool.DriftKey(
"remove_constraint",
"market_watchlists",
"uq_market_watchlist_owner_symbol",
),
schema_tool.DriftKey(
"modify_nullable",
"work_tasks",
"source_system",
),
*{
schema_tool.DriftKey("modify_default", table_name, column_name)
for table_name, columns in schema_tool._MODIFY_DEFAULTS.items()
for column_name in columns
},
}
assert len(schema_tool.ALLOWED_DRIFT - clean_005_only) == 125
@pytest.mark.parametrize(
("table", "constraint_name"),
[
(AIMemoryEntry.__table__, "uq_ai_memory_owner_fingerprint"),
(MarketWatchlist.__table__, "uq_market_watchlist_owner_symbol"),
],
)
def test_owner_unique_constraints_use_postgres_nulls_not_distinct(
table,
constraint_name: str,
) -> None:
constraint = next(
item for item in table.constraints if item.name == constraint_name
)
assert (
constraint.dialect_options["postgresql"]["nulls_not_distinct"] is True
)
postgres_ddl = str(CreateTable(table).compile(dialect=postgresql.dialect()))
sqlite_ddl = str(CreateTable(table).compile(dialect=sqlite.dialect()))
assert "UNIQUE NULLS NOT DISTINCT" in postgres_ddl
assert "NULLS NOT DISTINCT" not in sqlite_ddl
def test_clean_005_upgrades_to_head_with_zero_metadata_drift(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, config = _database(
tmp_path,
monkeypatch,
"clean-upgrade",
schema_tool.PREVIOUS_REVISION,
)
try:
command.upgrade(config, "head")
with engine.connect() as connection:
audit = schema_tool.audit_connection(connection)
assert audit.revision_rows == (schema_tool.TARGET_REVISION,)
assert audit.observed_drift == frozenset()
assert "approval_requests" not in inspect(engine).get_table_names()
command.check(config)
finally:
engine.dispose()
get_settings.cache_clear()
def test_baseline_stamps_and_upgrades_in_one_external_transaction(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, _ = _database(
tmp_path,
monkeypatch,
"baseline-success",
schema_tool.PREVIOUS_REVISION,
)
try:
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
)
_remove_revision(engine)
before = schema_tool.audit_engine(engine)
assert before.eligible is True
assert schema_tool.DriftKey(
"remove_table",
"approval_requests",
"",
) in before.observed_drift
after = schema_tool.apply_baseline(
engine,
before.fingerprint,
require_postgresql=False,
)
assert after.revision_rows == (schema_tool.TARGET_REVISION,)
assert after.observed_drift == frozenset()
assert "approval_requests" not in inspect(engine).get_table_names()
finally:
engine.dispose()
get_settings.cache_clear()
def test_baseline_failure_rolls_back_atomic_stamp(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, _ = _database(
tmp_path,
monkeypatch,
"baseline-rollback",
schema_tool.PREVIOUS_REVISION,
)
try:
_remove_revision(engine)
before = schema_tool.audit_engine(engine)
def fail_upgrade(*_args, **_kwargs) -> None:
raise RuntimeError("injected migration failure")
monkeypatch.setattr(schema_tool.command, "upgrade", fail_upgrade)
with pytest.raises(RuntimeError, match="injected migration failure"):
schema_tool.apply_baseline(
engine,
before.fingerprint,
require_postgresql=False,
)
with engine.connect() as connection:
revisions = tuple(
connection.execute(
text("SELECT version_num FROM alembic_version")
).scalars()
)
assert revisions == ()
finally:
engine.dispose()
get_settings.cache_clear()
def test_baseline_rejects_changed_fingerprint_before_stamp(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, _ = _database(
tmp_path,
monkeypatch,
"baseline-fingerprint",
schema_tool.PREVIOUS_REVISION,
)
try:
_remove_revision(engine)
with pytest.raises(schema_tool.ReconciliationError, match="fingerprint changed"):
schema_tool.apply_baseline(
engine,
"0" * 64,
require_postgresql=False,
)
with engine.connect() as connection:
assert schema_tool._revision_rows(connection) == ()
finally:
engine.dispose()
get_settings.cache_clear()
def test_preflight_fingerprint_includes_full_server_default_expression() -> None:
engine = create_engine("sqlite://")
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
lifecycle_state TEXT DEFAULT 'draft'
)
"""
)
)
with engine.connect() as connection:
first = schema_tool._schema_fingerprint(connection)
with engine.begin() as connection:
connection.execute(text("DROP TABLE projects"))
connection.execute(
text(
"""
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
lifecycle_state TEXT DEFAULT 'active'
)
"""
)
)
with engine.connect() as connection:
second = schema_tool._schema_fingerprint(connection)
assert first != second
finally:
engine.dispose()
def test_preflight_fingerprint_binds_impacted_row_counts_without_data() -> None:
engine = create_engine("sqlite://")
private_value = "must-not-appear-in-reconciliation-output"
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE approval_requests (
id INTEGER PRIMARY KEY,
private_payload TEXT
)
"""
)
)
before = schema_tool.audit_engine(engine)
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO approval_requests (id, private_payload)
VALUES (1, :private_payload)
"""
),
{"private_payload": private_value},
)
after = schema_tool.audit_engine(engine)
assert before.fingerprint != after.fingerprint
assert dict(before.impacted_table_row_counts) == {"approval_requests": 0}
assert dict(after.impacted_table_row_counts) == {"approval_requests": 1}
public_payload = after.public_dict()
assert public_payload["impacted_table_row_counts"] == {
"approval_requests": 1
}
assert "impacted_table_content_digests" not in public_payload
assert private_value not in json.dumps(public_payload)
finally:
engine.dispose()
def test_content_fingerprint_is_order_stable_and_detects_same_count_change() -> None:
engine = create_engine("sqlite://")
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE projects (
project_code TEXT,
private_payload TEXT
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO projects (project_code, private_payload)
VALUES ('P-1', 'first'), ('P-2', 'second')
"""
)
)
with engine.connect() as connection:
first = schema_tool._schema_fingerprint(connection)
with engine.begin() as connection:
connection.execute(text("DELETE FROM projects"))
connection.execute(
text(
"""
INSERT INTO projects (project_code, private_payload)
VALUES ('P-2', 'second'), ('P-1', 'first')
"""
)
)
with engine.connect() as connection:
reordered = schema_tool._schema_fingerprint(connection)
with engine.begin() as connection:
connection.execute(
text(
"""
UPDATE projects
SET private_payload = 'changed'
WHERE project_code = 'P-1'
"""
)
)
with engine.connect() as connection:
changed = schema_tool._schema_fingerprint(connection)
assert reordered == first
assert changed != first
finally:
engine.dispose()
def test_apply_rejects_same_row_count_content_change_after_dry_run(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, _ = _database(
tmp_path,
monkeypatch,
"baseline-content-fingerprint",
schema_tool.PREVIOUS_REVISION,
)
try:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO feishu_event_receipts (
event_key,
source,
event_id,
message_id,
received_at
)
VALUES (
'legacy-content-key',
'webhook',
'event-before',
'message-stable',
'2026-07-27 00:00:00'
)
"""
)
)
_remove_revision(engine)
before = schema_tool.audit_engine(engine)
with engine.begin() as connection:
connection.execute(
text(
"""
UPDATE feishu_event_receipts
SET event_id = 'event-after'
WHERE event_key = 'legacy-content-key'
"""
)
)
changed = schema_tool.audit_engine(engine)
assert changed.impacted_table_row_counts == before.impacted_table_row_counts
assert (
changed.impacted_table_content_digests
!= before.impacted_table_content_digests
)
assert changed.fingerprint != before.fingerprint
with pytest.raises(schema_tool.ReconciliationError, match="fingerprint changed"):
schema_tool.apply_baseline(
engine,
before.fingerprint,
require_postgresql=False,
)
with engine.connect() as connection:
assert schema_tool._revision_rows(connection) == ()
finally:
engine.dispose()
get_settings.cache_clear()
@pytest.mark.parametrize(
("database_url", "legacy_database_url"),
[
("sqlite:///local-platform.db", None),
(
"postgresql://platform@database/platform",
"postgresql+psycopg://legacy@database:5432/platform",
),
],
)
def test_cli_dry_run_rejects_unsafe_target_before_engine_creation(
database_url: str,
legacy_database_url: str | None,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
engine_creation_count = 0
def record_engine_creation(*_args, **_kwargs):
nonlocal engine_creation_count
engine_creation_count += 1
raise AssertionError("unsafe target reached create_engine")
monkeypatch.setattr(
schema_tool,
"get_settings",
lambda: SimpleNamespace(
database_url=database_url,
legacy_database_url=legacy_database_url,
),
)
monkeypatch.setattr(schema_tool, "create_engine", record_engine_creation)
monkeypatch.setattr("sys.argv", ["reconcile_platform_schema"])
with pytest.raises(SystemExit) as exit_info:
schema_tool.main()
assert exit_info.value.code == 2
assert engine_creation_count == 0
output = json.loads(capsys.readouterr().out)
assert output["applied"] is False
assert "error" in output
assert database_url not in output["error"]
assert legacy_database_url is None or legacy_database_url not in output["error"]
def test_postgres_below_15_is_ineligible_and_apply_precondition_rejects() -> None:
audit = schema_tool.BaselineAudit(
dialect="postgresql",
fingerprint="a" * 64,
impacted_table_row_counts=(),
impacted_table_content_digests=(),
postgresql_server_version_num=140012,
postgresql_version_supported=False,
revision_rows=(),
observed_drift=frozenset(),
unexpected_drift=frozenset(),
approval_rows=None,
approval_inbound_foreign_keys=0,
schema_privileges_ok=True,
table_ownership_ok=True,
)
assert audit.eligible is False
assert audit.public_dict()["postgresql_server_version_num"] == 140012
assert audit.public_dict()["postgresql_version_supported"] is False
with pytest.raises(schema_tool.ReconciliationError, match="PostgreSQL 15"):
schema_tool._validate_apply_preconditions(audit, audit.fingerprint)
def test_nonempty_approval_table_is_never_removed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, _ = _database(
tmp_path,
monkeypatch,
"baseline-nonempty-approval",
schema_tool.PREVIOUS_REVISION,
)
try:
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
)
connection.execute(text("INSERT INTO approval_requests (id) VALUES (1)"))
_remove_revision(engine)
before = schema_tool.audit_engine(engine)
assert before.eligible is False
assert before.approval_rows == 1
with pytest.raises(schema_tool.ReconciliationError, match="not empty"):
schema_tool.apply_baseline(
engine,
before.fingerprint,
require_postgresql=False,
)
with engine.connect() as connection:
assert connection.scalar(
text("SELECT COUNT(*) FROM approval_requests")
) == 1
assert schema_tool._revision_rows(connection) == ()
finally:
engine.dispose()
get_settings.cache_clear()
def test_reconciliation_downgrade_is_fail_closed_and_irreversible(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, config = _database(
tmp_path,
monkeypatch,
"irreversible-downgrade",
"202607270001",
)
try:
with engine.connect() as connection:
before = schema_tool._schema_fingerprint(connection)
with pytest.raises(RuntimeError, match="irreversible"):
command.downgrade(config, schema_tool.PREVIOUS_REVISION)
with engine.connect() as connection:
after = schema_tool._schema_fingerprint(connection)
revisions = schema_tool._revision_rows(connection)
assert after == before
assert revisions == ("202607270001",)
finally:
engine.dispose()
get_settings.cache_clear()
def test_reconciliation_backfills_required_legacy_values(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine, config = _database(
tmp_path,
monkeypatch,
"required-backfill",
schema_tool.PREVIOUS_REVISION,
)
try:
with engine.begin() as connection:
connection.execute(
text("ALTER TABLE work_tasks DROP COLUMN source_system")
)
connection.execute(
text(
"""
INSERT INTO work_tasks (
code,
title,
status,
priority,
created_at,
updated_at
)
VALUES (
'legacy-task',
'Legacy task',
'todo',
'P2',
'2026-07-27 00:00:00',
'2026-07-27 00:00:00'
)
"""
)
)
command.upgrade(config, "head")
with engine.connect() as connection:
source_system = connection.scalar(
text(
"""
SELECT source_system
FROM work_tasks
WHERE code = 'legacy-task'
"""
)
)
columns = {
column["name"]: column
for column in inspect(engine).get_columns("work_tasks")
}
assert source_system == "internal"
assert columns["source_system"]["nullable"] is False
assert columns["source_system"]["default"] is None
assert columns["is_active"]["default"] is None
finally:
engine.dispose()
get_settings.cache_clear()

View File

@@ -0,0 +1,281 @@
import os
import re
from collections.abc import Iterator
from uuid import uuid4
from alembic import command
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine, URL, make_url
from sqlalchemy.exc import ArgumentError, DBAPIError
from sqlalchemy.pool import NullPool
from app.core.config import get_settings
from app.tools import reconcile_platform_schema as schema_tool
TEST_DATABASE_ENV = "TEST_POSTGRES_DATABASE_URL"
TEST_SCHEMA_PATTERN = re.compile(r"caip_test_[0-9a-f]{32}\Z")
pytestmark = pytest.mark.skipif(
not os.getenv(TEST_DATABASE_ENV, "").strip(),
reason=f"{TEST_DATABASE_ENV} is not configured",
)
def _configured_postgres_url() -> URL:
raw_url = os.getenv(TEST_DATABASE_ENV, "").strip()
if not raw_url:
pytest.skip(f"{TEST_DATABASE_ENV} is not configured")
try:
url = make_url(raw_url)
except (ArgumentError, TypeError, ValueError):
pytest.fail(f"{TEST_DATABASE_ENV} is not a valid SQLAlchemy URL")
if url.get_backend_name() != "postgresql":
pytest.fail(f"{TEST_DATABASE_ENV} must use PostgreSQL")
return url
def _quoted_schema(engine: Engine, schema_name: str) -> str:
if not TEST_SCHEMA_PATTERN.fullmatch(schema_name):
raise RuntimeError("Refusing unsafe PostgreSQL test schema name")
return engine.dialect.identifier_preparer.quote_identifier(schema_name)
@pytest.fixture
def postgres_schema_engine(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Engine]:
base_url = _configured_postgres_url()
schema_name = f"caip_test_{uuid4().hex}"
admin_engine = create_engine(base_url, poolclass=NullPool)
isolated_engine: Engine | None = None
schema_created = False
try:
quoted_schema = _quoted_schema(admin_engine, schema_name)
with admin_engine.begin() as connection:
connection.exec_driver_sql(f"CREATE SCHEMA {quoted_schema}")
schema_created = True
isolated_url = base_url.update_query_dict(
{"options": f"-csearch_path={schema_name}"}
)
isolated_engine = create_engine(isolated_url, poolclass=NullPool)
monkeypatch.setenv(
"DATABASE_URL",
isolated_url.render_as_string(hide_password=False),
)
get_settings.cache_clear()
with isolated_engine.connect() as connection:
assert connection.scalar(text("SELECT current_schema()")) == schema_name
yield isolated_engine
finally:
if isolated_engine is not None:
isolated_engine.dispose()
get_settings.cache_clear()
if schema_created:
quoted_schema = _quoted_schema(admin_engine, schema_name)
with admin_engine.begin() as connection:
connection.exec_driver_sql(
f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE"
)
admin_engine.dispose()
def _upgrade_to_previous(engine: Engine) -> None:
with engine.begin() as connection:
command.upgrade(
schema_tool._alembic_config(connection),
schema_tool.PREVIOUS_REVISION,
)
def _prepare_mixed_unversioned_baseline(engine: Engine) -> None:
_upgrade_to_previous(engine)
with engine.begin() as connection:
connection.execute(
text("ALTER TABLE projects DROP COLUMN department_code")
)
connection.execute(
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
)
connection.execute(text("DELETE FROM alembic_version"))
def test_postgres_mixed_baseline_applies_to_head_without_metadata_drift(
postgres_schema_engine: Engine,
) -> None:
_prepare_mixed_unversioned_baseline(postgres_schema_engine)
before = schema_tool.audit_engine(postgres_schema_engine)
assert before.eligible is True
assert (
before.postgresql_server_version_num
and before.postgresql_server_version_num
>= schema_tool.MINIMUM_POSTGRESQL_VERSION_NUM
)
assert before.postgresql_version_supported is True
assert before.revision_rows == ()
assert schema_tool.DriftKey(
"add_column",
"projects",
"department_code",
) in before.observed_drift
assert schema_tool.DriftKey(
"remove_table",
schema_tool.APPROVAL_TABLE,
"",
) in before.observed_drift
after = schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
assert after.revision_rows == (schema_tool.TARGET_REVISION,)
assert after.observed_drift == frozenset()
assert after.unexpected_drift == frozenset()
assert schema_tool.APPROVAL_TABLE not in inspect(
postgres_schema_engine
).get_table_names()
def test_postgres_upgrade_failure_rolls_back_ddl_and_stamp(
postgres_schema_engine: Engine,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_upgrade_to_previous(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(text("DELETE FROM alembic_version"))
before = schema_tool.audit_engine(postgres_schema_engine)
def fail_upgrade(config, _revision: str) -> None:
connection = config.attributes["connection"]
connection.execute(
text("CREATE TABLE injected_upgrade_artifact (id INTEGER)")
)
connection.execute(
text("ALTER TABLE projects ADD COLUMN injected_upgrade_marker TEXT")
)
raise RuntimeError("injected PostgreSQL migration failure")
monkeypatch.setattr(schema_tool.command, "upgrade", fail_upgrade)
with pytest.raises(
RuntimeError,
match="injected PostgreSQL migration failure",
):
schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
with postgres_schema_engine.connect() as connection:
after = schema_tool.audit_connection(connection)
table_names = inspect(connection).get_table_names()
project_columns = {
column["name"]
for column in inspect(connection).get_columns("projects")
}
assert after.fingerprint == before.fingerprint
assert after.revision_rows == ()
assert "injected_upgrade_artifact" not in table_names
assert "injected_upgrade_marker" not in project_columns
def test_postgres_apply_rejects_same_count_content_change(
postgres_schema_engine: Engine,
) -> None:
_upgrade_to_previous(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO feishu_event_receipts (
event_key,
source,
event_id,
message_id,
received_at
)
VALUES (
'postgres-content-key',
'webhook',
'event-before',
'message-stable',
'2026-07-27 00:00:00'
)
"""
)
)
connection.execute(text("DELETE FROM alembic_version"))
before = schema_tool.audit_engine(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(
text(
"""
UPDATE feishu_event_receipts
SET event_id = 'event-after'
WHERE event_key = 'postgres-content-key'
"""
)
)
changed = schema_tool.audit_engine(postgres_schema_engine)
assert changed.impacted_table_row_counts == before.impacted_table_row_counts
assert (
changed.impacted_table_content_digests
!= before.impacted_table_content_digests
)
assert changed.fingerprint != before.fingerprint
with pytest.raises(schema_tool.ReconciliationError, match="fingerprint changed"):
schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
with postgres_schema_engine.connect() as connection:
assert schema_tool._revision_rows(connection) == ()
def test_postgres_advisory_transaction_lock_excludes_second_connection(
postgres_schema_engine: Engine,
) -> None:
with postgres_schema_engine.begin() as connection:
connection.execute(text("CREATE TABLE projects (id INTEGER PRIMARY KEY)"))
with (
postgres_schema_engine.connect() as first,
postgres_schema_engine.connect() as second,
):
first_transaction = first.begin()
second_transaction = second.begin()
try:
assert first.scalar(text("SELECT pg_backend_pid()")) != second.scalar(
text("SELECT pg_backend_pid()")
)
schema_tool._acquire_postgres_lock(first)
schema_tool._lock_impacted_postgres_tables(first)
second_acquired = second.scalar(
text("SELECT pg_try_advisory_xact_lock(:lock_key)"),
{"lock_key": schema_tool.ADVISORY_LOCK_KEY},
)
assert second_acquired is False
second.exec_driver_sql("SET LOCAL lock_timeout = '250ms'")
with pytest.raises(DBAPIError):
second.exec_driver_sql(
"LOCK TABLE projects IN ROW EXCLUSIVE MODE NOWAIT"
)
second_transaction.rollback()
first_transaction.commit()
second_transaction = second.begin()
second_acquired_after_release = second.scalar(
text("SELECT pg_try_advisory_xact_lock(:lock_key)"),
{"lock_key": schema_tool.ADVISORY_LOCK_KEY},
)
assert second_acquired_after_release is True
finally:
if first_transaction.is_active:
first_transaction.rollback()
if second_transaction.is_active:
second_transaction.rollback()

View File

@@ -3,7 +3,7 @@ from datetime import date, datetime, timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy import select, text
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import BusinessResponseKey, StatusValue
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.application.scheduling import create_scheduler
from app.core.database import Base, SessionLocal, engine
from app.core.database.migrations import expected_alembic_heads
from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.http.responses import MaskedJSONResponse
from app.core.security import require_api_key, require_audit_api_key
@@ -89,9 +90,14 @@ from app.application.delivery import ReportDeliveryService
from app.modules.reports.chart import render_lifecycle_chart
from app.modules.reports.services import ReportService
from app.modules.feishu.service import FeishuService
from app.modules.feishu.models import FeishuEventReceipt
from app.application.feishu import FeishuCommandService
from app.application.feishu.events import FeishuEventService, _audit_event_metadata
from app.modules.feishu.constants import FeishuEventSource
from app.application.feishu.events import (
FeishuEventService,
_audit_event_metadata,
_identifier_digest,
)
from app.modules.feishu.constants import FeishuEventSource, FeishuInboundStatus
from app.modules.risk.constants import RiskEventActionValue
from app.modules.market.chart import render_market_chart
from app.modules.market.service import MarketService, TushareClient, normalize_symbol
@@ -101,6 +107,18 @@ from app.modules.workflows.models import WorkflowInstance
Base.metadata.create_all(bind=engine)
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE IF NOT EXISTS alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
)
connection.execute(text("DELETE FROM alembic_version"))
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": expected_alembic_heads()[0]},
)
client = TestClient(app)
headers = {"X-API-Key": "test-key"}
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
@@ -183,8 +201,22 @@ def test_feishu_webhook_routes_message_event() -> None:
response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert response.status_code == 200
data = response.json()
assert data["handled"] is True
assert data["result"]["command"] == "risk_summary"
assert data["accepted"] is True
assert data["handled"] is False
assert data["status"] == FeishuInboundStatus.PENDING
with SessionLocal() as db:
receipt = db.scalar(
select(FeishuEventReceipt).where(
FeishuEventReceipt.event_id
== _identifier_digest(
"evt-smoke-risk-001",
"event-id",
)
)
)
assert receipt is not None
assert receipt.status == FeishuInboundStatus.SUCCEEDED
assert receipt.payload is None
audit_metadata = _audit_event_metadata(payload)
assert "content" not in json.dumps(audit_metadata)
assert "test-feishu-token" not in json.dumps(audit_metadata)
@@ -192,6 +224,7 @@ def test_feishu_webhook_routes_message_event() -> None:
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert duplicate_response.status_code == 200
assert duplicate_response.json()["duplicate"] is True
assert duplicate_response.json()["status"] == FeishuInboundStatus.SUCCEEDED
blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers)
assert blocked_logs_response.status_code == 401
@@ -200,7 +233,11 @@ def test_feishu_webhook_routes_message_event() -> None:
assert logs_response.status_code == 200
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
assert "test-feishu-token" not in audit_payload
assert "evt-smoke-risk-001" in audit_payload
assert "evt-smoke-risk-001" not in audit_payload
assert (
_identifier_digest("evt-smoke-risk-001", "event-id")
in audit_payload
)
def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None:

View File

@@ -330,6 +330,7 @@ def test_ready_route_returns_503_for_processable_delivery_without_credentials(
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
get_settings.cache_clear()
with session_factory() as db:
subscription = _seed_subscription(

View File

@@ -28,13 +28,16 @@ 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",
"database_url": (
"postgresql+psycopg://app:runtime-database-password-2026@db/app"
),
"api_key": "runtime-service-key-2026-primary",
"audit_api_key": "runtime-audit-key-2026-independent",
"cors_origins": ["https://internal.example.com"],
"debug": False,
"mask_sensitive_responses": True,
"read_only_mode": True,
"feishu_event_transport": "disabled",
}
values.update(overrides)
return Settings(_env_file=None, **values)
@@ -42,7 +45,7 @@ def _production_settings(**overrides: object) -> Settings:
def test_production_keys_must_be_enabled_isolated_and_safe() -> None:
settings = _production_settings()
assert settings.api_key == "service-key"
assert settings.api_key == "runtime-service-key-2026-primary"
with pytest.raises(ValueError, match="API_KEY or API_KEYS"):
_production_settings(
@@ -51,7 +54,7 @@ def test_production_keys_must_be_enabled_isolated_and_safe() -> None:
)
with pytest.raises(ValueError, match="cannot overlap"):
_production_settings(audit_api_key="service-key")
_production_settings(audit_api_key="runtime-service-key-2026-primary")
with pytest.raises(ValueError, match="MASK_SENSITIVE_RESPONSES"):
_production_settings(mask_sensitive_responses=False)