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 http_context(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv( "FINANCIAL_CONNECTOR_HMAC_KEYS_JSON", json.dumps({"connector/http-test": "http-server-secret"}), ) 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") user_box = {"current": _user("platform-admin", is_admin=True)} 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: user_box["current"] with factory() as db: claim = _claim(db) db.commit() client = TestClient(app) try: yield client, user_box, claim finally: client.close() app.dependency_overrides.clear() Base.metadata.drop_all(engine) engine.dispose() get_settings.cache_clear() def test_financial_connector_http_auth_config_and_reconciliation_scope(http_context) -> None: client, user_box, claim = http_context config = client.post( "/api/v1/financial-connectors/admin/tenants/default/configs", json={ "provider": "http-bank", "environment": "mock", "key_version": "v1", "secret_ref": "connector/http-test", "allowed_event_types": ["payment_settled"], "clock_skew_seconds": 300, "status": "disabled", "request_id": "http-config-create-001", "reason": "创建 HTTP 回归测试连接器配置。", }, ) assert config.status_code == 201 assert config.json()["status"] == "disabled" assert config.json()["version"] == 1 assert "secret_ref" not in config.json() assert "secret" not in json.dumps(config.json()) activated = client.post( "/api/v1/financial-connectors/admin/tenants/default/configs/" f"{config.json()['id']}/activate", json={ "expected_version": 1, "request_id": "http-config-activate-001", "reason": "完成服务端密钥校验后启用模拟连接器。", }, ) assert activated.status_code == 200 assert activated.json()["status"] == "active" assert activated.json()["version"] == 2 envelope = FinancialEventEnvelope( tenant_id="default", external_event_id="http-settlement-001", event_type="payment_settled", occurred_at=datetime.now(UTC), correlation_id="http-correlation-001", payload={ "claim_id": claim.id, "claim_reference": claim.claim_no, "amount": "66.00", "currency": "CNY", "external_payment_reference": "HTTP-PAYMENT-00001", }, ) timestamp = int(time.time()) headers = { "X-Financial-Tenant": "default", "X-Financial-Provider": "http-bank", "X-Financial-Key-Version": "v1", "X-Financial-Timestamp": str(timestamp), "X-Financial-Signature": sign_financial_event( envelope, timestamp=timestamp, secret="http-server-secret", tenant_id="default", provider="http-bank", key_version="v1", ), } bad = client.post( "/api/v1/integrations/financial-events", json=envelope.model_dump(mode="json"), headers={**headers, "X-Financial-Signature": "sha256=" + "0" * 64}, ) assert bad.status_code == 401 accepted = client.post( "/api/v1/integrations/financial-events", json=envelope.model_dump(mode="json"), headers=headers, ) replay = client.post( "/api/v1/integrations/financial-events", json=envelope.model_dump(mode="json"), headers=headers, ) conflicting_envelope = envelope.model_copy( update={"payload": {**envelope.payload, "amount": "66.01"}} ) conflicting_headers = { **headers, "X-Financial-Signature": sign_financial_event( conflicting_envelope, timestamp=timestamp, secret="http-server-secret", tenant_id="default", provider="http-bank", key_version="v1", ), } conflict = client.post( "/api/v1/integrations/financial-events", json=conflicting_envelope.model_dump(mode="json"), headers=conflicting_headers, ) assert accepted.status_code == 200 assert accepted.json()["claim_status"] == "pending_payment" assert accepted.json()["evidence_classification"] == "simulated_connector" assert accepted.json()["projection_scope"] == "simulation_only" assert accepted.json()["reconciliation_case_id"] is None assert replay.status_code == 200 and replay.json()["replayed"] is True assert conflict.status_code == 409 assert client.get("/api/v1/financial-reconciliation/cases").status_code == 403 user_box["current"] = _user("finance-default", roles=["finance"]) listed = client.get("/api/v1/financial-reconciliation/cases") observed = client.get("/api/v1/financial-connectors/observability") assert listed.status_code == 200 assert listed.json()["total"] == 0 assert observed.status_code == 200 assert observed.json()["summary"]["retry_count"] == 1 assert observed.json()["summary"]["auth_failure_count"] == 1 assert observed.json()["summary"]["signature_failure_count"] == 1 assert observed.json()["summary"]["payload_conflict_count"] == 1 assert observed.json()["window_hours"] == 24 assert observed.json()["source_revision"] == "20260716_0022" assert observed.json()["summary"]["latest_replay_at"] is not None assert observed.json()["summary"]["latest_auth_failure_at"] is not None assert observed.json()["summary"]["latest_payload_conflict_at"] is not None user_box["current"] = _user("finance-other", tenant_id="tenant-other", roles=["finance"]) assert client.get("/api/v1/financial-reconciliation/cases").json()["total"] == 0 other_observability = client.get("/api/v1/financial-connectors/observability") assert other_observability.json()["summary"]["auth_failure_count"] == 0 assert other_observability.json()["summary"]["payload_conflict_count"] == 0 user_box["current"] = _user("platform-admin", is_admin=True) events = client.get( "/api/v1/financial-connectors/admin/tenants/default/config-events" ) assert events.status_code == 200 assert [item["action"] for item in events.json()] == ["created", "activated"] assert "secret" not in json.dumps(events.json()).lower() def _claim(db: Session) -> ExpenseClaim: row = ExpenseClaim( id=str(uuid.uuid4()), claim_no="BX-CONNECTOR-HTTP-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(row) db.flush() return row def _user( username: str, *, tenant_id: str = "default", roles: list[str] | None = None, is_admin: bool = False, ) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=list(roles or []), is_admin=is_admin, tenant_id=tenant_id, )