feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from auth_helpers import install_legacy_header_auth_override
|
||||
@@ -17,9 +18,11 @@ from app.api.deps import 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.expense_case import ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
@@ -179,6 +182,7 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
|
||||
assert "top_departments" in dashboard_response.json()
|
||||
assert feedback_response.status_code == 200
|
||||
assert feedback_response.json()["feedback_type"] == "false_positive"
|
||||
assert feedback_response.json()["actor"] == "Test Admin"
|
||||
|
||||
updated_detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice")
|
||||
assert updated_detail_response.status_code == 200
|
||||
@@ -192,6 +196,237 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
|
||||
assert observation.feedback_status == "false_positive"
|
||||
|
||||
|
||||
def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor() -> None:
|
||||
client, session_factory = _build_client()
|
||||
with session_factory() as db:
|
||||
service = RiskObservationService(db)
|
||||
tenant_a = service.upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:tenant-a:duplicate_invoice"),
|
||||
"claim_id": "shared-claim",
|
||||
},
|
||||
tenant_id="tenant-a",
|
||||
execution_log_id="shared-execution-log",
|
||||
)
|
||||
tenant_b = service.upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:tenant-b:duplicate_invoice"),
|
||||
"claim_id": "shared-claim",
|
||||
},
|
||||
tenant_id="tenant-b",
|
||||
execution_log_id="shared-execution-log",
|
||||
)
|
||||
tenant_a_id = tenant_a.id
|
||||
tenant_b_id = tenant_b.id
|
||||
db.commit()
|
||||
|
||||
tenant_a_headers = {
|
||||
"X-Auth-Username": "auditor-a",
|
||||
"X-Auth-Name": "Tenant A Auditor",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
}
|
||||
tenant_b_headers = {
|
||||
"X-Auth-Username": "auditor-b",
|
||||
"X-Auth-Name": "Tenant B Auditor",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
}
|
||||
|
||||
list_response = client.get("/api/v1/risk-observations", headers=tenant_a_headers)
|
||||
detail_response = client.get(
|
||||
f"/api/v1/risk-observations/{tenant_a_id}",
|
||||
headers=tenant_a_headers,
|
||||
)
|
||||
foreign_detail_response = client.get(
|
||||
f"/api/v1/risk-observations/{tenant_b_id}",
|
||||
headers=tenant_a_headers,
|
||||
)
|
||||
claim_response = client.get(
|
||||
"/api/v1/risk-observations/claim/shared-claim",
|
||||
headers=tenant_a_headers,
|
||||
)
|
||||
execution_log_response = client.get(
|
||||
"/api/v1/risk-observations/execution-log/shared-execution-log",
|
||||
headers=tenant_a_headers,
|
||||
)
|
||||
dashboard_response = client.get(
|
||||
"/api/v1/risk-observations/dashboard",
|
||||
headers=tenant_a_headers,
|
||||
)
|
||||
foreign_feedback_response = client.post(
|
||||
f"/api/v1/risk-observations/{tenant_b_id}/feedback",
|
||||
headers=tenant_a_headers,
|
||||
json={"feedback_type": "confirm", "actor": "伪造管理员"},
|
||||
)
|
||||
own_feedback_response = client.post(
|
||||
f"/api/v1/risk-observations/{tenant_a_id}/feedback",
|
||||
headers=tenant_a_headers,
|
||||
json={"feedback_type": "confirm", "actor": "伪造管理员"},
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["total"] == 1
|
||||
assert list_response.json()["items"][0]["tenant_id"] == "tenant-a"
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["tenant_id"] == "tenant-a"
|
||||
assert foreign_detail_response.status_code == 404
|
||||
assert claim_response.status_code == 200
|
||||
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"]
|
||||
assert execution_log_response.status_code == 200
|
||||
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
|
||||
assert dashboard_response.status_code == 200
|
||||
assert dashboard_response.json()["total_observations"] == 1
|
||||
assert foreign_feedback_response.status_code == 404
|
||||
assert own_feedback_response.status_code == 200
|
||||
assert own_feedback_response.json()["actor"] == "Tenant A Auditor"
|
||||
|
||||
tenant_b_detail = client.get(
|
||||
f"/api/v1/risk-observations/{tenant_b_id}",
|
||||
headers=tenant_b_headers,
|
||||
)
|
||||
assert tenant_b_detail.status_code == 200
|
||||
assert tenant_b_detail.json()["status"] == "pending_review"
|
||||
assert tenant_b_detail.json()["feedback_items"] == []
|
||||
|
||||
|
||||
def test_risk_observation_service_scopes_history_and_same_key_upserts_by_tenant() -> None:
|
||||
with _build_session() as db:
|
||||
service = RiskObservationService(db)
|
||||
tenant_a = service.upsert_observation(
|
||||
_observation_payload("risk:shared:duplicate_invoice"),
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
tenant_b = service.upsert_observation(
|
||||
_observation_payload("risk:shared:duplicate_invoice"),
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
service.create_feedback(
|
||||
tenant_a.id,
|
||||
RiskObservationFeedbackCreate(feedback_type="confirm", actor="untrusted"),
|
||||
tenant_id="tenant-a",
|
||||
actor="trusted-auditor",
|
||||
)
|
||||
|
||||
tenant_a_items, tenant_a_total = service.list_observations(tenant_id="tenant-a")
|
||||
tenant_b_items, tenant_b_total = service.list_observations(tenant_id="tenant-b")
|
||||
tenant_a_history = service.build_history_stats(
|
||||
tenant_id="tenant-a",
|
||||
risk_signals={"duplicate_invoice"},
|
||||
)
|
||||
tenant_b_history = service.build_history_stats(
|
||||
tenant_id="tenant-b",
|
||||
risk_signals={"duplicate_invoice"},
|
||||
)
|
||||
|
||||
assert tenant_a.id != tenant_b.id
|
||||
assert tenant_a_total == tenant_b_total == 1
|
||||
assert [item.tenant_id for item in tenant_a_items] == ["tenant-a"]
|
||||
assert [item.tenant_id for item in tenant_b_items] == ["tenant-b"]
|
||||
assert tenant_a_history[0].confirmed_count == 1
|
||||
assert tenant_b_history[0].confirmed_count == 0
|
||||
assert tenant_a.feedback_items[0].actor == "trusted-auditor"
|
||||
assert service.get_observation(tenant_b.id, tenant_id="tenant-a") is None
|
||||
|
||||
|
||||
def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> None:
|
||||
with _build_session() as db:
|
||||
claim = _claim_orm("claim-tenant-boundary", "BX-TENANT-BOUNDARY")
|
||||
expense_case = ExpenseCase(
|
||||
id="case-tenant-boundary",
|
||||
tenant_id="tenant-a",
|
||||
case_no="CASE-TENANT-BOUNDARY",
|
||||
scene_code="reimbursement",
|
||||
title="租户边界测试",
|
||||
current_stage="claiming",
|
||||
status="active",
|
||||
)
|
||||
link = ExpenseCaseLink(
|
||||
id="link-tenant-boundary",
|
||||
tenant_id="tenant-a",
|
||||
expense_case_id=expense_case.id,
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
relation_type="claim",
|
||||
)
|
||||
db.add_all([claim, expense_case, link])
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(PermissionError, match="tenant does not match"):
|
||||
RiskObservationService(db).upsert_observation(
|
||||
{
|
||||
**_observation_payload("risk:tenant-boundary"),
|
||||
"claim_id": claim.id,
|
||||
},
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
|
||||
assert db.query(RiskObservation).filter_by(
|
||||
observation_key="risk:tenant-boundary"
|
||||
).one_or_none() is None
|
||||
|
||||
|
||||
def test_hermes_global_scan_builds_graphs_inside_each_tenant(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with _build_session() as db:
|
||||
claims = [
|
||||
_claim_orm("claim-tenant-a", "BX-TENANT-A"),
|
||||
_claim_orm("claim-tenant-b", "BX-TENANT-B"),
|
||||
]
|
||||
cases = [
|
||||
ExpenseCase(
|
||||
id=f"case-tenant-{suffix}",
|
||||
tenant_id=f"tenant-{suffix}",
|
||||
case_no=f"CASE-TENANT-{suffix.upper()}",
|
||||
scene_code="reimbursement",
|
||||
title="租户隔离图扫描",
|
||||
current_stage="claiming",
|
||||
status="active",
|
||||
)
|
||||
for suffix in ("a", "b")
|
||||
]
|
||||
links = [
|
||||
ExpenseCaseLink(
|
||||
id=f"link-tenant-{suffix}",
|
||||
tenant_id=f"tenant-{suffix}",
|
||||
expense_case_id=cases[index].id,
|
||||
resource_type="expense_claim",
|
||||
resource_id=claims[index].id,
|
||||
relation_type="claim",
|
||||
)
|
||||
for index, suffix in enumerate(("a", "b"))
|
||||
]
|
||||
db.add_all([*claims, *cases, *links])
|
||||
db.flush()
|
||||
|
||||
evaluated_claim_sets: list[set[str]] = []
|
||||
history_tenants: list[str] = []
|
||||
|
||||
def fake_evaluate(context):
|
||||
evaluated_claim_sets.append(set(context.target_claim_ids))
|
||||
return SimpleNamespace(observations=[], nodes=[], edges=[])
|
||||
|
||||
def fake_history(_self, *, tenant_id=None, **_kwargs):
|
||||
history_tenants.append(str(tenant_id))
|
||||
return []
|
||||
|
||||
scanner = HermesRiskScannerService(db)
|
||||
monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: claims)
|
||||
monkeypatch.setattr(
|
||||
"app.services.hermes_risk_scanner.evaluate_financial_risk_graph",
|
||||
fake_evaluate,
|
||||
)
|
||||
monkeypatch.setattr(RiskObservationService, "build_history_stats", fake_history)
|
||||
|
||||
summary = scanner.scan_global_risks()
|
||||
|
||||
assert evaluated_claim_sets == [
|
||||
{"claim-tenant-a"},
|
||||
{"claim-tenant-b"},
|
||||
]
|
||||
assert history_tenants == ["tenant-a", "tenant-b"]
|
||||
assert summary["scanned_claim_count"] == 2
|
||||
|
||||
|
||||
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
|
||||
with _build_session() as db:
|
||||
service = RiskObservationService(db)
|
||||
|
||||
Reference in New Issue
Block a user