Files
X-Financial/server/tests/test_risk_dispositions.py
2026-07-16 15:34:58 +08:00

662 lines
23 KiB
Python

from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDispositionEvent
from app.models.risk_observation import RiskObservationFeedback
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionIdempotencyConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
def test_risk_disposition_separates_adjudication_and_lifecycle_with_audit_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:duplicate")
)
db.commit()
service = RiskDispositionService(db)
confirmed = service.execute_action(
observation.id,
_action("confirm", version=0, request_id="request-confirm-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert confirmed.disposition.adjudication == "confirmed"
assert confirmed.disposition.lifecycle_status == "open"
assert confirmed.disposition.version == 1
assert confirmed.event.before_json["adjudication"] == "unreviewed"
assert confirmed.event.after_json["adjudication"] == "confirmed"
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=1,
request_id="request-supplement-001",
assignee="员工甲",
due_at=datetime.now(UTC) + timedelta(days=2),
comment="请补齐行程单",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "confirmed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
assert supplemented.disposition.assignee == "员工甲"
assert supplemented.disposition.version == 2
resolved = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=2,
request_id="request-resolve-001",
resolution="补充材料已核验,风险关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
assert resolved.disposition.resolution == "补充材料已核验,风险关闭。"
assert resolved.disposition.version == 3
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 3
assert db.scalar(select(func.count()).select_from(RiskObservationFeedback)) == 1
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"start_remediation",
version=3,
request_id="request-remediation-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionConflictError, match="已解决"):
service.execute_action(
observation.id,
_action(
"false_positive",
version=3,
request_id="request-readjudicate-after-resolve",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
def test_risk_disposition_requires_confirmation_before_remediation_or_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:transition-guard")
)
db.commit()
service = RiskDispositionService(db)
with pytest.raises(RiskDispositionConflictError, match="必须先确认成立"):
service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-unreviewed",
resolution="不能跳过裁决直接关闭。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
supplemented = service.execute_action(
observation.id,
RiskDispositionActionCreate(
action="request_supplement",
expected_version=0,
request_id="request-supplement-unreviewed",
comment="先补充材料再裁决。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert supplemented.disposition.adjudication == "unreviewed"
assert supplemented.disposition.lifecycle_status == "supplement_requested"
def test_risk_disposition_imports_legacy_confirmation_before_first_typed_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:legacy-confirmed")
)
observation.status = "confirmed"
observation.feedback_status = "confirmed"
db.commit()
resolved = RiskDispositionService(db).execute_action(
observation.id,
RiskDispositionActionCreate(
action="resolve",
expected_version=0,
request_id="request-resolve-legacy-confirmed",
resolution="历史确认风险已完成整改。",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert resolved.event.before_json["adjudication"] == "confirmed"
assert resolved.disposition.adjudication == "confirmed"
assert resolved.disposition.lifecycle_status == "resolved"
def test_risk_disposition_idempotency_and_optimistic_version_are_enforced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
with _build_session() as db:
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:typed:idempotency")
)
second_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:typed:idempotency:second"),
"claim_id": None,
"claim_no": "",
"subject_key": "standalone:second",
}
)
db.commit()
service = RiskDispositionService(db)
payload = _action("confirm", version=0, request_id="request-idempotent-001")
first = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
replay = service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert replay.replayed is True
assert replay.event.id == first.event.id
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 1
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
_action(
"false_positive",
version=0,
request_id="request-idempotent-001",
),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
second_observation.id,
payload,
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
with pytest.raises(RiskDispositionIdempotencyConflictError):
service.execute_action(
observation.id,
payload,
tenant_id="default",
actor_id="finance-2",
actor_name="财务乙",
)
with pytest.raises(RiskDispositionVersionConflictError) as error:
service.execute_action(
observation.id,
_action("request_waiver", version=0, request_id="request-stale-001"),
tenant_id="default",
actor_id="finance-1",
actor_name="财务甲",
)
assert error.value.current_version == 1
def test_risk_observation_api_enforces_pool_claim_and_typed_action_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
client, session_factory = _build_client()
with session_factory() as db:
db.add(_employee())
db.add(_claim())
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:api:duplicate")
)
standalone_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:standalone"),
"claim_id": None,
"claim_no": "",
"subject_key": "default:standalone",
}
)
foreign_observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:api:foreign"),
"claim_id": None,
"claim_no": "",
"subject_key": "tenant-b:standalone",
},
tenant_id="tenant-b",
)
observation_id = observation.id
standalone_observation_id = standalone_observation.id
foreign_observation_id = foreign_observation.id
db.commit()
employee_headers = {
"X-Auth-Username": "risk.employee@example.com",
"X-Auth-Name": "Risk Employee",
"X-Auth-Employee-No": "E-RISK",
}
finance_headers = {
"X-Auth-Username": "finance@example.com",
"X-Auth-Name": "Finance Reviewer",
"X-Auth-Role-Codes": "finance",
}
assert client.get("/api/v1/risk-observations", headers=employee_headers).status_code == 403
assert (
client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=employee_headers,
).status_code
== 403
)
assert (
client.get(
"/api/v1/risk-observations/claim/claim-risk-1",
headers=employee_headers,
).status_code
== 404
)
assert (
client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=employee_headers,
json={"feedback_type": "confirm"},
).status_code
== 403
)
first = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "人工复核确认",
},
)
stale = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "request_waiver",
"expected_version": 0,
"request_id": "api-stale-request-001",
"comment": "申请风险豁免复核",
},
)
changed_replay = client.post(
f"/api/v1/risk-observations/{observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "false_positive",
"expected_version": 0,
"request_id": "api-confirm-request-001",
"comment": "经核验属于误报",
},
)
unsafe_legacy = client.post(
f"/api/v1/risk-observations/{observation_id}/feedback",
headers=finance_headers,
json={"feedback_type": "comment", "payload_json": {"arbitrary": True}},
)
foreign_action = client.post(
f"/api/v1/risk-observations/{foreign_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-cross-tenant-001",
},
)
standalone_action = client.post(
f"/api/v1/risk-observations/{standalone_observation_id}/disposition/actions",
headers=finance_headers,
json={
"action": "confirm",
"expected_version": 0,
"request_id": "api-standalone-finance-001",
},
)
assert first.status_code == 200
assert first.json()["disposition"]["adjudication"] == "confirmed"
assert first.json()["disposition"]["lifecycle_status"] == "open"
assert replay.status_code == 200
assert replay.json()["replayed"] is True
assert replay.json()["event"]["id"] == first.json()["event"]["id"]
assert stale.status_code == 409
assert stale.json()["detail"]["code"] == "RISK_DISPOSITION_VERSION_CONFLICT"
assert stale.json()["detail"]["message"] == "风险处置状态已更新,请刷新证据链后重试。"
assert changed_replay.status_code == 409
assert unsafe_legacy.status_code == 410
assert foreign_action.status_code == 404
assert standalone_action.status_code == 403
detail = client.get(
f"/api/v1/risk-observations/{observation_id}",
headers=finance_headers,
)
assert detail.status_code == 200
assert detail.json()["disposition"]["version"] == 1
assert len(detail.json()["disposition"]["events"]) == 1
def test_current_claim_approver_can_manage_disposition_without_pool_access() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk",
employee_no="M-RISK",
name="风险主管",
email="risk.manager@example.com",
position="部门经理",
grade="P8",
)
employee = _employee()
employee.manager_id = manager.id
claim = _claim()
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
_observation_payload("risk:approver:duplicate")
)
db.commit()
current_user = CurrentUserContext(
username="risk.manager@example.com",
name="风险主管",
role_codes=["approver"],
is_admin=False,
employee_no="M-RISK",
)
policy = RiskObservationAccessPolicy(db)
assert policy.can_read_tenant_pool(current_user) is False
assert policy.can_read_claim_risks(claim.id, current_user) is True
assert policy.can_manage_disposition(observation, current_user) is True
unrelated_manager = CurrentUserContext(
username="unrelated.manager@example.com",
name="其他经理",
role_codes=["manager"],
is_admin=False,
)
finance_outside_stage = CurrentUserContext(
username="finance@example.com",
name="财务甲",
role_codes=["finance"],
is_admin=False,
)
assert policy.can_read_tenant_pool(unrelated_manager) is False
assert policy.can_read_claim_risks(claim.id, unrelated_manager) is False
assert policy.can_manage_disposition(observation, unrelated_manager) is False
assert policy.can_manage_disposition(observation, finance_outside_stage) is False
def test_disposition_rechecks_current_approver_after_claim_stage_changes() -> None:
with _build_session() as db:
manager = Employee(
id="manager-risk-stage-change",
employee_no="M-RISK-STAGE",
name="原审批主管",
email="risk.stage.manager@example.com",
)
employee = _employee()
employee.id = "emp-risk-stage-change"
employee.employee_no = "E-RISK-STAGE"
employee.email = "risk.stage.employee@example.com"
employee.manager_id = manager.id
claim = _claim()
claim.id = "claim-risk-stage-change"
claim.claim_no = "BX-RISK-STAGE"
claim.employee_id = employee.id
claim.approval_stage = "直属领导审批"
db.add_all([manager, employee, claim])
db.flush()
observation = RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:approver:stage-change"),
"claim_id": claim.id,
"claim_no": claim.claim_no,
"subject_key": f"claim:{claim.id}",
}
)
db.commit()
current_user = CurrentUserContext(
username=manager.email,
name=manager.name,
role_codes=["manager"],
is_admin=False,
employee_no=manager.employee_no,
)
claim.approval_stage = "财务审批"
db.commit()
with pytest.raises(RiskDispositionPermissionError, match="不再是"):
RiskDispositionService(db).execute_action(
observation.id,
_action("confirm", version=0, request_id="request-stage-changed-001"),
tenant_id="default",
actor_id=manager.id,
actor_name=manager.name,
current_user=current_user,
)
assert db.scalar(select(func.count()).select_from(RiskDispositionEvent)) == 0
@pytest.mark.parametrize(
"action",
["false_positive", "request_supplement", "request_waiver"],
)
def test_evidence_sensitive_actions_require_server_side_comment(action: str) -> None:
with pytest.raises(ValueError, match="必须填写 comment"):
RiskDispositionActionCreate(
action=action,
expected_version=0,
request_id=f"request-comment-{action}",
)
def _action(
action: str,
*,
version: int,
request_id: str,
) -> RiskDispositionActionCreate:
return RiskDispositionActionCreate(
action=action,
expected_version=version,
request_id=request_id,
comment=(
"风险处置说明"
if action in {"false_positive", "request_supplement", "request_waiver"}
else None
),
)
def _build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return factory()
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = FastAPI()
app.include_router(risk_observations_router, prefix="/api/v1")
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), factory
def _employee() -> Employee:
return Employee(
id="emp-risk",
employee_no="E-RISK",
name="风险员工",
email="risk.employee@example.com",
position="高级专员",
grade="P6",
)
def _claim() -> ExpenseClaim:
now = datetime(2026, 7, 16, tzinfo=UTC)
return ExpenseClaim(
id="claim-risk-1",
claim_no="BX-RISK-001",
employee_id="emp-risk",
employee_name="风险员工",
department_id="dept-risk",
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
def _observation_payload(observation_key: str) -> dict[str, object]:
return {
"observation_key": observation_key,
"subject_type": "expense_claim",
"subject_key": "claim:claim-risk-1",
"subject_label": "BX-RISK-001",
"claim_id": "claim-risk-1",
"claim_no": "BX-RISK-001",
"risk_type": "duplicate_invoice",
"risk_signal": "duplicate_invoice",
"title": "重复票据风险",
"description": "同一票据可能重复报销。",
"risk_score": 86,
"risk_level": "high",
"confidence_score": 0.91,
"control_stage": "reimbursement",
"control_mode": "risk_observation",
"automation_mode": "semi_auto_review",
"source": "financial_risk_graph",
"algorithm_version": "financial_risk_graph.v1",
"contribution_scores": {},
"baseline": {},
"evidence": [],
"graph_node_keys": [],
"graph_edge_keys": [],
"policy_refs": [],
"similar_case_claim_ids": [],
"ontology_json": {},
"decision_trace": {},
}