```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -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": "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
1220
tests/test_feishu_inbound_reliability.py
Normal file
1220
tests/test_feishu_inbound_reliability.py
Normal file
File diff suppressed because it is too large
Load Diff
226
tests/test_feishu_long_connection_health.py
Normal file
226
tests/test_feishu_long_connection_health.py
Normal 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()
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
608
tests/test_runtime_activation.py
Normal file
608
tests/test_runtime_activation.py
Normal 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
|
||||
626
tests/test_schema_reconciliation.py
Normal file
626
tests/test_schema_reconciliation.py
Normal 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()
|
||||
281
tests/test_schema_reconciliation_postgres.py
Normal file
281
tests/test_schema_reconciliation_postgres.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user