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