212 lines
7.0 KiB
Python
212 lines
7.0 KiB
Python
|
|
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,
|
||
|
|
)
|