Files
X-Financial/server/tests/test_hermes_finance_tenant_security.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

306 lines
10 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings
from app.db.base import Base
from app.models.agent_run import AgentRun, AgentToolCall
from app.models.financial_record import ExpenseClaim
from app.models.tenant import Tenant
from app.models.tenant_finance_report import TenantFinanceReportRun
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.digital_employee_finance_report_task import (
DigitalEmployeeFinanceReportTaskService,
)
from app.services.finance_report_tenant import TenantFinanceReportConfigService
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
from app.services.hermes_risk_scanner import HermesRiskScannerService
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 _tenant(tenant_id: str) -> Tenant:
return Tenant(
tenant_id=tenant_id,
tenant_code=tenant_id,
name=f"{tenant_id} 公司",
status="active",
)
def _claim(
*,
tenant_id: str,
claim_id: str,
claim_no: str,
amount: str,
now: datetime,
) -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
tenant_id=tenant_id,
claim_no=claim_no,
employee_name=f"{tenant_id} 员工",
department_name=f"{tenant_id} 财务部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal(amount),
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=now - timedelta(days=1),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=1),
updated_at=now,
)
def _digital_run(
*,
tenant_id: str,
run_id: str,
scanned_claim_count: int,
now: datetime,
) -> AgentRun:
return AgentRun(
run_id=run_id,
agent="hermes",
source="schedule",
user_id="digital_employee",
status="succeeded",
route_json={
"tenant_id": tenant_id,
"task_type": "global_risk_scan",
},
ontology_json={"tenant_id": tenant_id},
result_summary="风险扫描完成。",
started_at=now - timedelta(minutes=2),
finished_at=now - timedelta(minutes=1),
tool_calls=[
AgentToolCall(
run_id=run_id,
tool_type="rule_engine",
tool_name="digital_employee.financial_risk_graph.scan",
request_json={"tenant_id": tenant_id},
response_json={"scanned_claim_count": scanned_claim_count},
status="succeeded",
duration_ms=10,
created_at=now - timedelta(minutes=2),
)
],
)
def test_hermes_scanners_and_dashboard_do_not_mix_tenants() -> None:
factory = _session_factory()
now = datetime.now(UTC)
with factory() as db:
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
db.add_all(
[
_claim(
tenant_id="tenant-a",
claim_id="claim-a",
claim_no="RE-A-001",
amount="100.00",
now=now,
),
_claim(
tenant_id="tenant-b",
claim_id="claim-b",
claim_no="RE-B-SECRET",
amount="9000.00",
now=now,
),
_digital_run(
tenant_id="tenant-a",
run_id="run-a",
scanned_claim_count=1,
now=now,
),
_digital_run(
tenant_id="tenant-b",
run_id="run-b",
scanned_claim_count=99,
now=now,
),
]
)
db.commit()
fetched = HermesRiskScannerService(db)._fetch_unscanned_claims(tenant_id="tenant-a")
assert [row.id for row in fetched] == ["claim-a"]
clues = HermesRiskClueCollectorService(db).collect_risk_clues(tenant_id="tenant-a")
assert clues["tenant_id"] == "tenant-a"
assert [row["claim_no"] for row in clues["facts"]] == ["RE-A-001"]
dashboard = DigitalEmployeeDashboardService(
db,
tenant_id="tenant-a",
).build_dashboard(days=7)
assert dashboard.totals["totalRuns"] == 1
assert dashboard.totals["riskObservations"] == 0
assert [row["runId"] for row in dashboard.recent_runs] == ["run-a"]
def test_finance_report_recipients_data_path_and_idempotency_are_tenant_bound(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path))
get_settings.cache_clear()
factory = _session_factory()
now = datetime.now(UTC)
start_date = (now - timedelta(days=3)).date()
end_date = now.date()
try:
with factory() as db:
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
db.add_all(
[
_claim(
tenant_id="tenant-a",
claim_id="claim-a",
claim_no="RE-A-001",
amount="100.00",
now=now,
),
_claim(
tenant_id="tenant-b",
claim_id="claim-b",
claim_no="RE-B-001",
amount="9000.00",
now=now,
),
]
)
db.commit()
config_service = TenantFinanceReportConfigService(db)
config_service.upsert(
tenant_id="tenant-a",
recipients=["finance-a@example.com"],
delivery_enabled=True,
updated_by="finance-a",
)
config_service.upsert(
tenant_id="tenant-b",
recipients=["finance-b@example.com"],
delivery_enabled=True,
updated_by="finance-b",
)
assert config_service.configured_recipients(tenant_id="tenant-a") == [
"finance-a@example.com"
]
assert (
config_service.configured_recipients(
tenant_id="tenant-a",
requested=["finance-b@example.com"],
)
== []
)
assert config_service.configured_recipients(tenant_id="missing") == []
task = DigitalEmployeeFinanceReportTaskService(db)
report_a = task.generate_report(
report_type="weekly",
start_date=start_date,
end_date=end_date,
tenant_id="tenant-a",
send_email=False,
)
replay_a = task.generate_report(
report_type="weekly",
start_date=start_date,
end_date=end_date,
tenant_id="tenant-a",
send_email=False,
)
report_b = task.generate_report(
report_type="weekly",
start_date=start_date,
end_date=end_date,
tenant_id="tenant-b",
send_email=False,
)
assert report_a["summary"]["reimbursement_count"] == 1
assert report_a["summary"]["reimbursement_amount"] == 100.0
assert report_b["summary"]["reimbursement_count"] == 1
assert report_b["summary"]["reimbursement_amount"] == 9000.0
assert replay_a["idempotent_replay"] is True
assert report_a["pdf"]["storage_key"] != report_b["pdf"]["storage_key"]
assert "/tenants/" in report_a["pdf"]["storage_key"]
assert "tenant-a" not in report_a["pdf"]["storage_key"]
ledger_rows = list(db.scalars(select(TenantFinanceReportRun)).all())
assert len(ledger_rows) == 2
assert {row.tenant_id for row in ledger_rows} == {"tenant-a", "tenant-b"}
assert len({row.idempotency_key for row in ledger_rows}) == 2
runs = list(db.scalars(select(AgentRun).where(AgentRun.agent == "hermes")))
tenant_a_runs = [
row for row in runs if (row.route_json or {}).get("tenant_id") == "tenant-a"
]
assert len(tenant_a_runs) == 1
assert tenant_a_runs[0].ontology_json["tenant_id"] == "tenant-a"
finally:
get_settings.cache_clear()
class _UnsupportedDialectOperationGuard:
def __init__(self) -> None:
self.bind = type("Bind", (), {"dialect": type("Dialect", (), {"name": "sqlite"})()})()
self.mutation_calls: list[str] = []
def get_bind(self) -> Any:
return self.bind
def __getattr__(self, name: str) -> Any:
self.mutation_calls.append(name)
raise AssertionError(f"unsupported dialect attempted migration operation: {name}")
@pytest.mark.parametrize("direction", ["upgrade", "downgrade"])
def test_hermes_tenant_migration_rejects_unsupported_dialect_before_mutation(
direction: str,
) -> None:
migration_path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260717_0028_hermes_ontology_tenant_security.py"
)
spec = spec_from_file_location("migration_20260717_0028_test", migration_path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
guard = _UnsupportedDialectOperationGuard()
module.op = guard
with pytest.raises(RuntimeError, match="only supports PostgreSQL"):
getattr(module, direction)()
assert guard.mutation_calls == []