Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
240 lines
8.5 KiB
Python
240 lines
8.5 KiB
Python
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,
|
|
)
|