Files
X-Financial/server/tests/test_financial_connector_config_lifecycle.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

300 lines
10 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 lifecycle_http(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(
"FINANCIAL_CONNECTOR_HMAC_KEYS_JSON",
json.dumps(
{
"connector/valid": "v" * 32,
"connector/next": "n" * 32,
"connector/weak": "short",
"connector/blank": " " * 32,
}
),
)
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")
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: CurrentUserContext(
username="lifecycle-admin",
name="连接器管理员",
role_codes=["platform_admin"],
is_admin=True,
tenant_id="default",
)
with factory() as db:
claim = ExpenseClaim(
id=str(uuid.uuid4()),
claim_no="BX-CONFIG-LIFECYCLE-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(claim)
db.commit()
client = TestClient(app)
try:
yield client, claim
finally:
client.close()
app.dependency_overrides.clear()
Base.metadata.drop_all(engine)
engine.dispose()
get_settings.cache_clear()
def test_activate_rotate_disable_is_versioned_and_audited(lifecycle_http) -> None:
client, claim = lifecycle_http
bypass = client.post(
"/api/v1/financial-connectors/admin/tenants/default/configs",
json={
"provider": "bypass-bank",
"environment": "production",
"key_version": "v1",
"secret_ref": "connector/valid",
"allowed_event_types": ["payment_settled"],
"status": "active",
"request_id": "lifecycle-bypass-001",
"reason": "创建时不得绕过显式激活和服务端密钥校验。",
},
)
assert bypass.status_code == 422
created = _create_config(client, secret_ref="connector/valid")
config_id = created["id"]
assert created["status"] == "disabled" and created["version"] == 1
activated = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/activate",
json={
"expected_version": 1,
"request_id": "lifecycle-activate-001",
"reason": "生产切换前完成服务端密钥解析与强度校验。",
},
)
assert activated.status_code == 200
assert activated.json()["status"] == "active"
assert activated.json()["version"] == 2
stale = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/disable",
json={
"expected_version": 1,
"request_id": "lifecycle-disable-stale-001",
"reason": "使用过期版本停用必须被拒绝。",
},
)
assert stale.status_code == 409
rotated = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/rotate",
json={
"expected_version": 2,
"request_id": "lifecycle-rotate-001",
"reason": "按季度轮换服务端 HMAC 密钥。",
"new_key_version": "v2",
"new_secret_ref": "connector/next",
},
)
assert rotated.status_code == 200
previous = rotated.json()["previous"]
replacement = rotated.json()["replacement"]
assert previous["status"] == "rotating" and previous["version"] == 3
assert replacement["status"] == "active" and replacement["version"] == 1
assert replacement["key_version"] == "v2"
envelope = FinancialEventEnvelope(
tenant_id="default",
external_event_id="lifecycle-simulation-001",
event_type="payment_settled",
occurred_at=datetime.now(UTC),
correlation_id="lifecycle-correlation-001",
payload={
"claim_id": claim.id,
"claim_reference": claim.claim_no,
"amount": "66.00",
"currency": "CNY",
"external_payment_reference": "LIFECYCLE-PAYMENT-001",
},
)
timestamp = int(time.time())
old_response = client.post(
"/api/v1/integrations/financial-events",
json=envelope.model_dump(mode="json"),
headers=_headers(envelope, timestamp, version="v1", secret="v" * 32),
)
assert old_response.status_code == 401
assert old_response.json()["detail"]["code"] == "connector_inactive"
new_response = client.post(
"/api/v1/integrations/financial-events",
json=envelope.model_dump(mode="json"),
headers=_headers(envelope, timestamp, version="v2", secret="n" * 32),
)
assert new_response.status_code == 200
assert new_response.json()["projection_scope"] == "simulation_only"
assert new_response.json()["claim_status"] == "pending_payment"
disabled = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/disable",
json={
"expected_version": 3,
"request_id": "lifecycle-disable-001",
"reason": "新版本已稳定接管,停用旧轮换版本。",
},
)
assert disabled.status_code == 200
assert disabled.json()["status"] == "disabled"
assert disabled.json()["version"] == 4
events = client.get(
"/api/v1/financial-connectors/admin/tenants/default/config-events"
)
assert events.status_code == 200
payload = events.json()
assert [item["action"] for item in payload] == [
"created",
"activated",
"rotation_started",
"rotation_replacement_created",
"disabled",
]
assert all(item["actor_id"] == "lifecycle-admin" for item in payload)
serialized = json.dumps(payload).lower()
assert "secret_ref" not in serialized
assert "connector/valid" not in serialized
assert "connector/next" not in serialized
@pytest.mark.parametrize(
("secret_ref", "error_code"),
[
("connector/missing", "secret_unavailable"),
("connector/weak", "secret_too_short"),
("connector/blank", "secret_too_short"),
],
)
def test_activate_fails_closed_when_server_secret_is_unusable(
lifecycle_http,
secret_ref: str,
error_code: str,
) -> None:
client, _ = lifecycle_http
created = _create_config(
client,
provider=f"invalid-{error_code}",
secret_ref=secret_ref,
request_id=f"create-{error_code}-001",
)
response = client.post(
"/api/v1/financial-connectors/admin/tenants/default/configs/"
f"{created['id']}/activate",
json={
"expected_version": 1,
"request_id": f"activate-{error_code}-001",
"reason": "不可用的服务端密钥不得进入激活态。",
},
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == error_code
configs = client.get(
"/api/v1/financial-connectors/admin/tenants/default/configs"
).json()
current = next(item for item in configs if item["id"] == created["id"])
assert current["status"] == "disabled"
assert current["version"] == 1
def _create_config(
client: TestClient,
*,
provider: str = "lifecycle-bank",
secret_ref: str,
request_id: str = "lifecycle-create-001",
) -> dict[str, object]:
response = client.post(
"/api/v1/financial-connectors/admin/tenants/default/configs",
json={
"provider": provider,
"environment": "mock",
"key_version": "v1",
"secret_ref": secret_ref,
"allowed_event_types": ["payment_settled"],
"clock_skew_seconds": 300,
"status": "disabled",
"request_id": request_id,
"reason": "创建连接器配置并等待显式激活。",
},
)
assert response.status_code == 201
return response.json()
def _headers(
envelope: FinancialEventEnvelope,
timestamp: int,
*,
version: str,
secret: str,
) -> dict[str, str]:
return {
"X-Financial-Tenant": "default",
"X-Financial-Provider": "lifecycle-bank",
"X-Financial-Key-Version": version,
"X-Financial-Timestamp": str(timestamp),
"X-Financial-Signature": sign_financial_event(
envelope,
timestamp=timestamp,
secret=secret,
tenant_id="default",
provider="lifecycle-bank",
key_version=version,
),
}