300 lines
10 KiB
Python
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,
|
||
|
|
),
|
||
|
|
}
|