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