from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.db.base_class import Base from app.models.financial_connector import ( FinancialConnectorConfig, FinancialConnectorOperationalEvent, ) from app.schemas.financial_connector import FinancialEventEnvelope from app.services.financial_connector_auth import ( FinancialConnectorAuthenticator, FinancialConnectorAuthError, FinancialConnectorSecretResolver, ) from app.services.financial_connector_observability import ( FinancialConnectorObservabilityService, ) from app.services.financial_connector_operational_events import ( FinancialConnectorOperationalContext, FinancialConnectorOperationalEventService, operational_event_candidate, ) @pytest.fixture() def db() -> Session: 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) with factory() as session: yield session Base.metadata.drop_all(engine) engine.dispose() def test_operational_attempts_are_counted_but_candidate_retry_is_idempotent( db: Session, ) -> None: config = _config(db) context = _context(config) as_of = datetime(2026, 7, 17, 10, 2, tzinfo=UTC) first = operational_event_candidate( context, event_type="replay", reason_code="duplicate_external_event", occurred_at=as_of - timedelta(minutes=2), ) second = operational_event_candidate( context, event_type="replay", reason_code="duplicate_external_event", occurred_at=as_of - timedelta(minutes=1), ) outside_window = operational_event_candidate( context, event_type="auth_failure", reason_code="signature_invalid", occurred_at=as_of - timedelta(hours=2), ) service = FinancialConnectorOperationalEventService(db) service.record(first) service.record(first) service.record(second) service.record(outside_window) db.commit() assert first.idempotency_key != second.idempotency_key assert db.scalar(select(func.count(FinancialConnectorOperationalEvent.id))) == 3 observed = FinancialConnectorObservabilityService(db, now=as_of).read_for_tenant( config.tenant_id, window_hours=1, ) assert observed.window_started_at == as_of - timedelta(hours=1) assert observed.as_of == as_of assert observed.source_revision == "20260716_0022" assert observed.summary.retry_count == 2 assert observed.summary.auth_failure_count == 0 assert observed.summary.latest_replay_at == second.occurred_at def test_auth_failure_is_attributed_only_after_trusted_config_and_secret_resolution( db: Session, ) -> None: config = _config(db) secret = "trusted-server-secret" now_epoch = 1_800_000_000 envelope = FinancialEventEnvelope( tenant_id=config.tenant_id, external_event_id="raw-external-event-must-not-be-stored", event_type="payment_settled", occurred_at=datetime.fromtimestamp(now_epoch, UTC), correlation_id="raw-correlation-must-not-be-stored", payload={ "claim_id": str(uuid.uuid4()), "claim_reference": "BX-RAW-MUST-NOT-BE-STORED", "amount": "66.00", "currency": "CNY", "external_payment_reference": "RAW-PAYMENT-MUST-NOT-BE-STORED", }, ) authenticator = FinancialConnectorAuthenticator( db, secrets=FinancialConnectorSecretResolver({config.secret_ref: secret}), now_epoch=now_epoch, ) with pytest.raises(FinancialConnectorAuthError) as forged_tenant: authenticator.verify( envelope, tenant_header="tenant-forged", provider_header=config.provider, key_version_header=config.key_version, timestamp_header=str(now_epoch), signature_header="sha256=" + "0" * 64, ) assert forged_tenant.value.operational_context is None with pytest.raises(FinancialConnectorAuthError) as forged_provider: authenticator.verify( envelope, tenant_header=config.tenant_id, provider_header="provider-forged", key_version_header=config.key_version, timestamp_header=str(now_epoch), signature_header="sha256=" + "0" * 64, ) assert forged_provider.value.operational_context is None with pytest.raises(FinancialConnectorAuthError) as trusted_failure: authenticator.verify( envelope, tenant_header=config.tenant_id, provider_header=config.provider, key_version_header=config.key_version, timestamp_header=str(now_epoch), signature_header="sha256=" + "0" * 64, ) context = trusted_failure.value.operational_context assert context is not None assert context.request_fingerprint.startswith("hmac-sha256:") assert context.external_event_fingerprint.startswith("hmac-sha256:") candidate = operational_event_candidate( context, event_type="auth_failure", reason_code=trusted_failure.value.code, occurred_at=datetime.fromtimestamp(now_epoch, UTC), ) FinancialConnectorOperationalEventService(db).record(candidate) db.commit() row = db.scalar(select(FinancialConnectorOperationalEvent)) assert row is not None stored = "|".join( ( row.provider, row.reason_code, row.request_fingerprint, row.external_event_fingerprint, row.idempotency_key, ) ) for raw_value in ( secret, envelope.external_event_id, envelope.correlation_id, envelope.payload["claim_reference"], envelope.payload["external_payment_reference"], ): assert str(raw_value) not in stored def _config(db: Session) -> FinancialConnectorConfig: row = FinancialConnectorConfig( id=str(uuid.uuid4()), tenant_id="tenant-operational", provider="operational-bank", environment="production", key_version="v1", secret_ref="connector/operational-test", allowed_event_types_json=["payment_settled"], clock_skew_seconds=300, status="active", created_by="operational-test", ) db.add(row) db.flush() return row def _context(config: FinancialConnectorConfig) -> FinancialConnectorOperationalContext: return FinancialConnectorOperationalContext( tenant_id=config.tenant_id, config_id=config.id, provider=config.provider, environment=config.environment, request_fingerprint="hmac-sha256:" + "a" * 64, external_event_fingerprint="hmac-sha256:" + "b" * 64, )