Files
X-Financial/server/tests/test_financial_connector_endpoints.py

240 lines
8.5 KiB
Python
Raw Normal View History

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,
)