feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
239
server/tests/test_financial_connector_endpoints.py
Normal file
239
server/tests/test_financial_connector_endpoints.py
Normal file
@@ -0,0 +1,239 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user