feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
530
server/tests/test_finance_dashboard_tenant_security.py
Normal file
530
server/tests/test_finance_dashboard_tenant_security.py
Normal file
@@ -0,0 +1,530 @@
|
||||
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, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, 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.agent_runs import router as agent_runs_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.db.base import Base
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy
|
||||
from app.services.finance_dashboard_scope import resolve_finance_dashboard_data_scope
|
||||
from app.services.finance_dashboard_snapshot import (
|
||||
FINANCE_DASHBOARD_TASK_TYPE,
|
||||
FinanceDashboardSnapshotService,
|
||||
)
|
||||
|
||||
|
||||
def _session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def _claim(
|
||||
*,
|
||||
claim_id: str,
|
||||
claim_no: str,
|
||||
amount: str,
|
||||
tenant_id: str = "default",
|
||||
) -> ExpenseClaim:
|
||||
now = datetime.now(UTC)
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
tenant_id=tenant_id,
|
||||
claim_no=claim_no,
|
||||
employee_name=f"employee-{claim_id}",
|
||||
department_name="财务部",
|
||||
expense_type="travel",
|
||||
reason="tenant scope test",
|
||||
location="上海",
|
||||
amount=Decimal(amount),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(hours=2),
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(hours=2),
|
||||
updated_at=now - timedelta(minutes=30),
|
||||
)
|
||||
|
||||
|
||||
def _link_claim(db: Session, claim: ExpenseClaim, *, tenant_id: str) -> None:
|
||||
expense_case = ExpenseCase(
|
||||
id=f"case-{claim.id}",
|
||||
tenant_id=tenant_id,
|
||||
case_no=f"CASE-{claim.claim_no}",
|
||||
scene_code="travel",
|
||||
title=f"{claim.claim_no} case",
|
||||
current_stage="completed",
|
||||
status="completed",
|
||||
)
|
||||
db.add(expense_case)
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseCaseLink(
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
relation_type="reimbursement",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _seed_tenant_claim(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
claim_id: str,
|
||||
claim_no: str,
|
||||
amount: str,
|
||||
) -> ExpenseClaim:
|
||||
claim = _claim(
|
||||
claim_id=claim_id,
|
||||
claim_no=claim_no,
|
||||
amount=amount,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
_link_claim(db, claim, tenant_id=tenant_id)
|
||||
return claim
|
||||
|
||||
|
||||
def _seed_finance_snapshot_run(
|
||||
db: Session,
|
||||
*,
|
||||
run_id: str,
|
||||
tenant_id: str | None,
|
||||
amount: str,
|
||||
route_data_scope: str | None = None,
|
||||
) -> None:
|
||||
route_json = {
|
||||
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
|
||||
"snapshot_key": f"snapshot-{run_id}",
|
||||
"snapshot_payload": {
|
||||
"tenant_marker": tenant_id or "legacy-unscoped",
|
||||
"amount": amount,
|
||||
},
|
||||
}
|
||||
ontology_json = {"scenario": "finance_dashboard"}
|
||||
if tenant_id is not None:
|
||||
expected_scope = resolve_finance_dashboard_data_scope(tenant_id)
|
||||
route_json["tenant_id"] = tenant_id
|
||||
route_json["data_scope"] = route_data_scope or expected_scope
|
||||
ontology_json["tenant_id"] = tenant_id
|
||||
ontology_json["data_scope"] = expected_scope
|
||||
|
||||
db.add(
|
||||
AgentRun(
|
||||
run_id=run_id,
|
||||
agent="hermes",
|
||||
source="system_event",
|
||||
user_id="digital_employee",
|
||||
ontology_json=ontology_json,
|
||||
route_json=route_json,
|
||||
permission_level="read",
|
||||
status="succeeded",
|
||||
result_summary=f"sensitive amount {amount}",
|
||||
started_at=datetime.now(UTC),
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
db.add(
|
||||
AgentToolCall(
|
||||
run_id=run_id,
|
||||
tool_type="database",
|
||||
tool_name="digital_employee.finance_dashboard.snapshot",
|
||||
request_json={"tenant_id": tenant_id, "amount": amount},
|
||||
response_json={"secret_amount": amount},
|
||||
status="succeeded",
|
||||
duration_ms=1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_finance_dashboard_isolates_claims_and_hides_legacy_budget_outside_default() -> None:
|
||||
now = datetime.now(UTC)
|
||||
session_factory = _session_factory()
|
||||
|
||||
with session_factory() as db:
|
||||
db.add(_claim(claim_id="legacy-default", claim_no="CLM-LEGACY-001", amount="100.00"))
|
||||
_seed_tenant_claim(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
claim_id="claim-a",
|
||||
claim_no="CLM-TENANT-A-001",
|
||||
amount="200.00",
|
||||
)
|
||||
_seed_tenant_claim(
|
||||
db,
|
||||
tenant_id="tenant-b",
|
||||
claim_id="claim-b",
|
||||
claim_no="CLM-TENANT-B-001",
|
||||
amount="300.00",
|
||||
)
|
||||
db.add(
|
||||
BudgetAllocation(
|
||||
budget_no="BUD-LEGACY-DEFAULT-001",
|
||||
fiscal_year=now.year,
|
||||
period_type="year",
|
||||
period_key=str(now.year),
|
||||
department_name="财务部",
|
||||
subject_code="travel",
|
||||
subject_name="差旅费",
|
||||
original_amount=Decimal("10000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="warn",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
default_dashboard = FinanceDashboardService(db).build_dashboard()
|
||||
tenant_a_dashboard = FinanceDashboardService(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
).build_dashboard()
|
||||
tenant_b_dashboard = FinanceDashboardService(
|
||||
db,
|
||||
tenant_id="tenant-b",
|
||||
).build_dashboard()
|
||||
|
||||
assert default_dashboard.totals["reimbursementCount"] == 1
|
||||
assert default_dashboard.totals["reimbursementAmount"] == 100.0
|
||||
assert default_dashboard.budget_summary["included"] is True
|
||||
assert default_dashboard.budget_summary["total"] == "¥10,000"
|
||||
|
||||
assert tenant_a_dashboard.totals["reimbursementCount"] == 1
|
||||
assert tenant_a_dashboard.totals["reimbursementAmount"] == 200.0
|
||||
assert "CLM-TENANT-B-001" not in str(tenant_a_dashboard.top_claims)
|
||||
assert tenant_a_dashboard.budget_summary == {
|
||||
"ratio": 0.0,
|
||||
"total": "¥0",
|
||||
"used": "¥0",
|
||||
"left": "¥0",
|
||||
"included": False,
|
||||
"scope": "unavailable",
|
||||
"reason": "当前租户尚未接入独立预算池,预算指标未纳入统计。",
|
||||
}
|
||||
assert tenant_a_dashboard.totals["budgetUsageRate"] == 0.0
|
||||
assert all(
|
||||
metric["detail"] == "当前租户未接入独立预算池" and metric["tone"] == "neutral"
|
||||
for metric in tenant_a_dashboard.budget_metrics
|
||||
)
|
||||
assert "预算超支" not in {item["name"] for item in tenant_a_dashboard.bottlenecks}
|
||||
|
||||
assert tenant_b_dashboard.totals["reimbursementCount"] == 1
|
||||
assert tenant_b_dashboard.totals["reimbursementAmount"] == 300.0
|
||||
assert tenant_b_dashboard.budget_summary["included"] is False
|
||||
|
||||
|
||||
def test_finance_dashboard_uses_structured_claim_tenant_when_legacy_link_disagrees() -> None:
|
||||
session_factory = _session_factory()
|
||||
|
||||
with session_factory() as db:
|
||||
claim = _claim(
|
||||
tenant_id="tenant-a",
|
||||
claim_id="structured-tenant-claim",
|
||||
claim_no="CLM-STRUCTURED-TENANT-001",
|
||||
amount="456.00",
|
||||
)
|
||||
db.add(claim)
|
||||
db.flush()
|
||||
# Case Link 是历史关联索引,不能覆盖 Claim 自身的结构化租户归属。
|
||||
_link_claim(db, claim, tenant_id="tenant-b")
|
||||
db.commit()
|
||||
|
||||
tenant_a_dashboard = FinanceDashboardService(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
).build_dashboard()
|
||||
tenant_b_dashboard = FinanceDashboardService(
|
||||
db,
|
||||
tenant_id="tenant-b",
|
||||
).build_dashboard()
|
||||
|
||||
assert tenant_a_dashboard.totals["reimbursementCount"] == 1
|
||||
assert tenant_a_dashboard.totals["reimbursementAmount"] == 456.0
|
||||
assert tenant_b_dashboard.totals["reimbursementCount"] == 0
|
||||
|
||||
|
||||
def test_finance_dashboard_snapshot_cache_is_partitioned_by_tenant_and_data_scope() -> None:
|
||||
session_factory = _session_factory()
|
||||
|
||||
with session_factory() as db:
|
||||
_seed_tenant_claim(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
claim_id="snapshot-claim-a",
|
||||
claim_no="CLM-SNAPSHOT-A-001",
|
||||
amount="880.00",
|
||||
)
|
||||
_seed_tenant_claim(
|
||||
db,
|
||||
tenant_id="tenant-b",
|
||||
claim_id="snapshot-claim-b",
|
||||
claim_no="CLM-SNAPSHOT-B-001",
|
||||
amount="990.00",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
tenant_a_service = FinanceDashboardSnapshotService(db, tenant_id="tenant-a")
|
||||
tenant_b_service = FinanceDashboardSnapshotService(db, tenant_id="tenant-b")
|
||||
first_a = tenant_a_service.build_dashboard()
|
||||
first_b = tenant_b_service.build_dashboard()
|
||||
second_a = tenant_a_service.build_dashboard()
|
||||
|
||||
runs = list(
|
||||
db.scalars(
|
||||
select(AgentRun).where(
|
||||
AgentRun.route_json["task_type"].as_string() == FINANCE_DASHBOARD_TASK_TYPE
|
||||
)
|
||||
).all()
|
||||
)
|
||||
routes = [run.route_json or {} for run in runs]
|
||||
|
||||
assert first_a.totals["reimbursementAmount"] == 880.0
|
||||
assert first_b.totals["reimbursementAmount"] == 990.0
|
||||
assert second_a.generated_at == first_a.generated_at
|
||||
assert len(runs) == 2
|
||||
assert {route["tenant_id"] for route in routes} == {"tenant-a", "tenant-b"}
|
||||
assert all(route["data_scope"] for route in routes)
|
||||
assert len({route["snapshot_key"] for route in routes}) == 2
|
||||
assert all(route["tenant_id"] in route["snapshot_key"] for route in routes)
|
||||
|
||||
|
||||
def test_default_scheduler_snapshot_rejects_non_default_tenant_scope() -> None:
|
||||
session_factory = _session_factory()
|
||||
|
||||
with session_factory() as db:
|
||||
service = FinanceDashboardSnapshotService(db, tenant_id="tenant-a")
|
||||
with pytest.raises(ValueError, match="default 系统租户"):
|
||||
service.refresh_default_snapshot()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tenant_id", ["", " "])
|
||||
def test_finance_dashboard_access_policy_rejects_missing_tenant_scope(
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-without-tenant",
|
||||
name="Finance Without Tenant",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
assert FinanceDashboardAccessPolicy.can_read(current_user) is False
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FinanceDashboardAccessPolicy.require_read(current_user)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_finance_dashboard_endpoint_requires_finance_read_role_and_passes_tenant() -> None:
|
||||
session_factory = _session_factory()
|
||||
with session_factory() as db:
|
||||
_seed_tenant_claim(
|
||||
db,
|
||||
tenant_id="tenant-a",
|
||||
claim_id="endpoint-claim-a",
|
||||
claim_no="CLM-ENDPOINT-A-001",
|
||||
amount="1234.00",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(analytics_router)
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
ordinary_response = client.get(
|
||||
"/analytics/finance-dashboard",
|
||||
headers={
|
||||
"X-Auth-Username": "ordinary",
|
||||
"X-Auth-Role-Codes": "user",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
},
|
||||
)
|
||||
budget_monitor_response = client.get(
|
||||
"/analytics/finance-dashboard",
|
||||
headers={
|
||||
"X-Auth-Username": "budget-monitor",
|
||||
"X-Auth-Role-Codes": "budget_monitor",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
},
|
||||
)
|
||||
finance_response = client.get(
|
||||
"/analytics/finance-dashboard",
|
||||
headers={
|
||||
"X-Auth-Username": "finance",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
},
|
||||
)
|
||||
executive_other_tenant_response = client.get(
|
||||
"/analytics/finance-dashboard",
|
||||
headers={
|
||||
"X-Auth-Username": "executive",
|
||||
"X-Auth-Role-Codes": "executive",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
},
|
||||
)
|
||||
admin_response = client.get(
|
||||
"/analytics/finance-dashboard",
|
||||
headers={
|
||||
"X-Auth-Username": "admin-reader",
|
||||
"X-Auth-Is-Admin": "true",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
},
|
||||
)
|
||||
|
||||
assert ordinary_response.status_code == 403
|
||||
assert budget_monitor_response.status_code == 403
|
||||
assert finance_response.status_code == 200
|
||||
assert finance_response.json()["totals"]["reimbursementAmount"] == 1234.0
|
||||
assert executive_other_tenant_response.status_code == 200
|
||||
assert executive_other_tenant_response.json()["totals"]["reimbursementCount"] == 0
|
||||
assert admin_response.status_code == 200
|
||||
|
||||
|
||||
def test_agent_run_endpoint_cannot_bypass_finance_snapshot_tenant_and_role_scope() -> None:
|
||||
session_factory = _session_factory()
|
||||
with session_factory() as db:
|
||||
_seed_finance_snapshot_run(
|
||||
db,
|
||||
run_id="run-finance-tenant-a",
|
||||
tenant_id="tenant-a",
|
||||
amount="111.00",
|
||||
)
|
||||
_seed_finance_snapshot_run(
|
||||
db,
|
||||
run_id="run-finance-tenant-b",
|
||||
tenant_id="tenant-b",
|
||||
amount="222.00",
|
||||
)
|
||||
_seed_finance_snapshot_run(
|
||||
db,
|
||||
run_id="run-finance-legacy-unscoped",
|
||||
tenant_id=None,
|
||||
amount="999.00",
|
||||
)
|
||||
_seed_finance_snapshot_run(
|
||||
db,
|
||||
run_id="run-finance-corrupt-scope",
|
||||
tenant_id="tenant-a",
|
||||
amount="777.00",
|
||||
route_data_scope="claims:corrupt;budget:corrupt",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(agent_runs_router)
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
finance_a_headers = {
|
||||
"X-Auth-Username": "finance-a",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
}
|
||||
finance_b_headers = {
|
||||
"X-Auth-Username": "finance-b",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
}
|
||||
|
||||
finance_a_list = client.get("/agent-runs", headers=finance_a_headers)
|
||||
ordinary_a_list = client.get(
|
||||
"/agent-runs",
|
||||
headers={
|
||||
"X-Auth-Username": "ordinary-a",
|
||||
"X-Auth-Role-Codes": "user",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
},
|
||||
)
|
||||
assert finance_a_list.status_code == 200
|
||||
assert [item["run_id"] for item in finance_a_list.json()] == ["run-finance-tenant-a"]
|
||||
assert ordinary_a_list.status_code == 200
|
||||
assert ordinary_a_list.json() == []
|
||||
|
||||
allowed_detail = client.get(
|
||||
"/agent-runs/run-finance-tenant-a",
|
||||
headers=finance_a_headers,
|
||||
)
|
||||
assert allowed_detail.status_code == 200
|
||||
assert allowed_detail.json()["route_json"]["snapshot_payload"]["amount"] == "111.00"
|
||||
assert allowed_detail.json()["tool_calls"][0]["response_json"] == {"secret_amount": "111.00"}
|
||||
|
||||
cross_tenant_detail = client.get(
|
||||
"/agent-runs/run-finance-tenant-a",
|
||||
headers=finance_b_headers,
|
||||
)
|
||||
cross_tenant_admin_detail = client.get(
|
||||
"/agent-runs/run-finance-tenant-a",
|
||||
headers={
|
||||
"X-Auth-Username": "admin-b",
|
||||
"X-Auth-Is-Admin": "true",
|
||||
"X-Auth-Tenant-Id": "tenant-b",
|
||||
},
|
||||
)
|
||||
same_tenant_ordinary_detail = client.get(
|
||||
"/agent-runs/run-finance-tenant-a",
|
||||
headers={
|
||||
"X-Auth-Username": "ordinary-a",
|
||||
"X-Auth-Role-Codes": "user",
|
||||
"X-Auth-Tenant-Id": "tenant-a",
|
||||
},
|
||||
)
|
||||
legacy_unscoped_detail = client.get(
|
||||
"/agent-runs/run-finance-legacy-unscoped",
|
||||
headers={
|
||||
"X-Auth-Username": "finance-default",
|
||||
"X-Auth-Role-Codes": "finance",
|
||||
"X-Auth-Tenant-Id": "default",
|
||||
},
|
||||
)
|
||||
corrupt_scope_detail = client.get(
|
||||
"/agent-runs/run-finance-corrupt-scope",
|
||||
headers=finance_a_headers,
|
||||
)
|
||||
|
||||
assert cross_tenant_detail.status_code == 404
|
||||
assert cross_tenant_admin_detail.status_code == 404
|
||||
assert same_tenant_ordinary_detail.status_code == 403
|
||||
assert legacy_unscoped_detail.status_code == 404
|
||||
assert corrupt_scope_detail.status_code == 404
|
||||
Reference in New Issue
Block a user