feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
305
server/tests/test_hermes_finance_tenant_security.py
Normal file
305
server/tests/test_hermes_finance_tenant_security.py
Normal file
@@ -0,0 +1,305 @@
|
||||
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 == []
|
||||
Reference in New Issue
Block a user