from __future__ import annotations import json import time import uuid from collections.abc import Generator from datetime import UTC, datetime from decimal import Decimal import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.api.deps import CurrentUserContext, get_current_user, get_db from app.api.v1.endpoints.financial_connectors import router from app.core.config import get_settings from app.db.base_class import Base from app.models.financial_record import ExpenseClaim from app.schemas.financial_connector import FinancialEventEnvelope from app.services.financial_connector_auth import sign_financial_event @pytest.fixture() def lifecycle_http(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv( "FINANCIAL_CONNECTOR_HMAC_KEYS_JSON", json.dumps( { "connector/valid": "v" * 32, "connector/next": "n" * 32, "connector/weak": "short", "connector/blank": " " * 32, } ), ) get_settings.cache_clear() engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) app = FastAPI() app.include_router(router, prefix="/api/v1") def override_db() -> Generator[Session, None, None]: with factory() as db: yield db app.dependency_overrides[get_db] = override_db app.dependency_overrides[get_current_user] = lambda: CurrentUserContext( username="lifecycle-admin", name="连接器管理员", role_codes=["platform_admin"], is_admin=True, tenant_id="default", ) with factory() as db: claim = ExpenseClaim( id=str(uuid.uuid4()), claim_no="BX-CONFIG-LIFECYCLE-001", employee_name="配置回归员工", department_name="财务测试部", expense_type="travel", reason="连接器配置生命周期回归", location="上海", amount=Decimal("66.00"), currency="CNY", invoice_count=1, occurred_at=datetime.now(UTC), submitted_at=datetime.now(UTC), status="pending_payment", approval_stage="待付款", risk_flags_json=[], ) db.add(claim) db.commit() client = TestClient(app) try: yield client, claim finally: client.close() app.dependency_overrides.clear() Base.metadata.drop_all(engine) engine.dispose() get_settings.cache_clear() def test_activate_rotate_disable_is_versioned_and_audited(lifecycle_http) -> None: client, claim = lifecycle_http bypass = client.post( "/api/v1/financial-connectors/admin/tenants/default/configs", json={ "provider": "bypass-bank", "environment": "production", "key_version": "v1", "secret_ref": "connector/valid", "allowed_event_types": ["payment_settled"], "status": "active", "request_id": "lifecycle-bypass-001", "reason": "创建时不得绕过显式激活和服务端密钥校验。", }, ) assert bypass.status_code == 422 created = _create_config(client, secret_ref="connector/valid") config_id = created["id"] assert created["status"] == "disabled" and created["version"] == 1 activated = client.post( f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/activate", json={ "expected_version": 1, "request_id": "lifecycle-activate-001", "reason": "生产切换前完成服务端密钥解析与强度校验。", }, ) assert activated.status_code == 200 assert activated.json()["status"] == "active" assert activated.json()["version"] == 2 stale = client.post( f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/disable", json={ "expected_version": 1, "request_id": "lifecycle-disable-stale-001", "reason": "使用过期版本停用必须被拒绝。", }, ) assert stale.status_code == 409 rotated = client.post( f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/rotate", json={ "expected_version": 2, "request_id": "lifecycle-rotate-001", "reason": "按季度轮换服务端 HMAC 密钥。", "new_key_version": "v2", "new_secret_ref": "connector/next", }, ) assert rotated.status_code == 200 previous = rotated.json()["previous"] replacement = rotated.json()["replacement"] assert previous["status"] == "rotating" and previous["version"] == 3 assert replacement["status"] == "active" and replacement["version"] == 1 assert replacement["key_version"] == "v2" envelope = FinancialEventEnvelope( tenant_id="default", external_event_id="lifecycle-simulation-001", event_type="payment_settled", occurred_at=datetime.now(UTC), correlation_id="lifecycle-correlation-001", payload={ "claim_id": claim.id, "claim_reference": claim.claim_no, "amount": "66.00", "currency": "CNY", "external_payment_reference": "LIFECYCLE-PAYMENT-001", }, ) timestamp = int(time.time()) old_response = client.post( "/api/v1/integrations/financial-events", json=envelope.model_dump(mode="json"), headers=_headers(envelope, timestamp, version="v1", secret="v" * 32), ) assert old_response.status_code == 401 assert old_response.json()["detail"]["code"] == "connector_inactive" new_response = client.post( "/api/v1/integrations/financial-events", json=envelope.model_dump(mode="json"), headers=_headers(envelope, timestamp, version="v2", secret="n" * 32), ) assert new_response.status_code == 200 assert new_response.json()["projection_scope"] == "simulation_only" assert new_response.json()["claim_status"] == "pending_payment" disabled = client.post( f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/disable", json={ "expected_version": 3, "request_id": "lifecycle-disable-001", "reason": "新版本已稳定接管,停用旧轮换版本。", }, ) assert disabled.status_code == 200 assert disabled.json()["status"] == "disabled" assert disabled.json()["version"] == 4 events = client.get( "/api/v1/financial-connectors/admin/tenants/default/config-events" ) assert events.status_code == 200 payload = events.json() assert [item["action"] for item in payload] == [ "created", "activated", "rotation_started", "rotation_replacement_created", "disabled", ] assert all(item["actor_id"] == "lifecycle-admin" for item in payload) serialized = json.dumps(payload).lower() assert "secret_ref" not in serialized assert "connector/valid" not in serialized assert "connector/next" not in serialized @pytest.mark.parametrize( ("secret_ref", "error_code"), [ ("connector/missing", "secret_unavailable"), ("connector/weak", "secret_too_short"), ("connector/blank", "secret_too_short"), ], ) def test_activate_fails_closed_when_server_secret_is_unusable( lifecycle_http, secret_ref: str, error_code: str, ) -> None: client, _ = lifecycle_http created = _create_config( client, provider=f"invalid-{error_code}", secret_ref=secret_ref, request_id=f"create-{error_code}-001", ) response = client.post( "/api/v1/financial-connectors/admin/tenants/default/configs/" f"{created['id']}/activate", json={ "expected_version": 1, "request_id": f"activate-{error_code}-001", "reason": "不可用的服务端密钥不得进入激活态。", }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == error_code configs = client.get( "/api/v1/financial-connectors/admin/tenants/default/configs" ).json() current = next(item for item in configs if item["id"] == created["id"]) assert current["status"] == "disabled" assert current["version"] == 1 def _create_config( client: TestClient, *, provider: str = "lifecycle-bank", secret_ref: str, request_id: str = "lifecycle-create-001", ) -> dict[str, object]: response = client.post( "/api/v1/financial-connectors/admin/tenants/default/configs", json={ "provider": provider, "environment": "mock", "key_version": "v1", "secret_ref": secret_ref, "allowed_event_types": ["payment_settled"], "clock_skew_seconds": 300, "status": "disabled", "request_id": request_id, "reason": "创建连接器配置并等待显式激活。", }, ) assert response.status_code == 201 return response.json() def _headers( envelope: FinancialEventEnvelope, timestamp: int, *, version: str, secret: str, ) -> dict[str, str]: return { "X-Financial-Tenant": "default", "X-Financial-Provider": "lifecycle-bank", "X-Financial-Key-Version": version, "X-Financial-Timestamp": str(timestamp), "X-Financial-Signature": sign_financial_event( envelope, timestamp=timestamp, secret=secret, tenant_id="default", provider="lifecycle-bank", key_version=version, ), }