Files
X-Financial/server/tests/test_ontology_employee_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

408 lines
14 KiB
Python

from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
import pytest
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_current_user, get_db
from app.db.base import Base
from app.main import create_app
from app.models.agent_run import AgentRun
from app.models.employee import Employee
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
)
from app.models.organization import OrganizationUnit
from app.models.tenant import Tenant
from app.schemas.ontology import OntologyParseRequest
from app.schemas.orchestrator import OrchestratorRequest
from app.services.ontology import SemanticOntologyService
from app.services.orchestrator import OrchestratorService
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 _employee(
*,
tenant_id: str,
employee_id: str,
employee_no: str,
name: str,
email: str,
manager_id: str | None = None,
) -> Employee:
return Employee(
id=employee_id,
tenant_id=tenant_id,
employee_no=employee_no,
name=name,
email=email,
manager_id=manager_id,
)
def _claim(
*,
tenant_id: str,
claim_id: str,
claim_no: str,
employee_id: str,
employee_name: str,
department_id: str,
department_name: str,
project_code: str,
) -> ExpenseClaim:
now = datetime.now(UTC)
return ExpenseClaim(
id=claim_id,
tenant_id=tenant_id,
claim_no=claim_no,
employee_id=employee_id,
employee_name=employee_name,
department_id=department_id,
department_name=department_name,
project_code=project_code,
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("100.00"),
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
def test_ontology_reference_catalog_never_reads_another_tenant() -> None:
factory = _session_factory()
today = datetime.now(UTC).date()
with factory() as db:
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
db.add_all(
[
OrganizationUnit(
id="dept-a",
tenant_id="tenant-a",
unit_code="A-FIN",
name="甲方财务部",
),
OrganizationUnit(
id="dept-b",
tenant_id="tenant-b",
unit_code="B-FIN",
name="乙方机密部门",
),
_employee(
tenant_id="tenant-a",
employee_id="employee-a",
employee_no="A001",
name="甲方员工",
email="employee-a@example.com",
),
_employee(
tenant_id="tenant-b",
employee_id="employee-b",
employee_no="B001",
name="乙方机密员工",
email="employee-b@example.com",
),
]
)
db.add_all(
[
_claim(
tenant_id="tenant-a",
claim_id="claim-a",
claim_no="RE-A-001",
employee_id="employee-a",
employee_name="甲方员工",
department_id="dept-a",
department_name="甲方财务部",
project_code="PROJECT-A",
),
_claim(
tenant_id="tenant-b",
claim_id="claim-b",
claim_no="RE-B-001",
employee_id="employee-b",
employee_name="乙方机密员工",
department_id="dept-b",
department_name="乙方机密部门",
project_code="PROJECT-B-SECRET",
),
AccountsReceivableRecord(
tenant_id="tenant-a",
receivable_no="AR-A-001",
customer_id="customer-a",
customer_name="甲方客户",
amount_receivable=Decimal("100"),
amount_received=Decimal("0"),
amount_outstanding=Decimal("100"),
posting_date=today,
due_date=today,
status="open",
),
AccountsReceivableRecord(
tenant_id="tenant-b",
receivable_no="AR-B-001",
customer_id="customer-b",
customer_name="乙方机密客户",
amount_receivable=Decimal("200"),
amount_received=Decimal("0"),
amount_outstanding=Decimal("200"),
posting_date=today,
due_date=today,
status="open",
),
AccountsPayableRecord(
tenant_id="tenant-a",
payable_no="AP-A-001",
vendor_id="vendor-a",
vendor_name="甲方供应商",
amount_payable=Decimal("100"),
amount_paid=Decimal("0"),
amount_outstanding=Decimal("100"),
posting_date=today,
due_date=today,
status="open",
),
AccountsPayableRecord(
tenant_id="tenant-b",
payable_no="AP-B-001",
vendor_id="vendor-b",
vendor_name="乙方机密供应商",
amount_payable=Decimal("200"),
amount_paid=Decimal("0"),
amount_outstanding=Decimal("200"),
posting_date=today,
due_date=today,
status="open",
),
]
)
db.commit()
catalog = SemanticOntologyService(db)._load_reference_catalog(tenant_id="tenant-a")
assert catalog.employees == ["甲方员工"]
assert catalog.departments == ["甲方财务部"]
assert catalog.customers == ["甲方客户"]
assert catalog.vendors == ["甲方供应商"]
assert catalog.projects == ["PROJECT-A"]
all_values = [
*catalog.employees,
*catalog.departments,
*catalog.customers,
*catalog.vendors,
*catalog.projects,
]
assert all("机密" not in value for value in all_values)
def test_ontology_creates_tenant_run_before_model_and_persists_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
factory = _session_factory()
with factory() as db:
db.add(_tenant("tenant-a"))
db.commit()
service = SemanticOntologyService(db)
observed: dict[str, str] = {}
def fake_model_parse(**kwargs):
context = kwargs["operation_context"]
run = db.scalar(select(AgentRun).where(AgentRun.run_id == context.run_id))
assert run is not None
assert run.status == "running"
assert run.route_json["tenant_id"] == "tenant-a"
assert run.route_json["phase"] == "pending_model_analysis"
assert context.tenant_id == "tenant-a"
observed["run_id"] = context.run_id
return None, [], None
monkeypatch.setattr(service, "_parse_with_model", fake_model_parse)
result = service.parse(
OntologyParseRequest(query="查询本月报销金额", user_id="employee-a"),
tenant_id="tenant-a",
)
assert result.run_id == observed["run_id"]
succeeded = db.scalar(select(AgentRun).where(AgentRun.run_id == result.run_id))
assert succeeded is not None
assert succeeded.route_json["tenant_id"] == "tenant-a"
assert succeeded.ontology_json["tenant_id"] == "tenant-a"
with pytest.raises(ValueError, match="仅支持财务业务"):
service.parse(
OntologyParseRequest(query="今天天气怎么样", user_id="employee-a"),
tenant_id="tenant-a",
)
failed = db.scalars(
select(AgentRun).where(AgentRun.status == "failed").order_by(AgentRun.started_at.desc())
).first()
assert failed is not None
assert failed.route_json["tenant_id"] == "tenant-a"
assert failed.route_json["phase"] == "failed"
def test_employee_profile_api_hides_cross_tenant_and_enforces_manager_scope() -> None:
factory = _session_factory()
with factory() as db:
db.add_all([_tenant("tenant-a"), _tenant("tenant-b")])
db.add_all(
[
_employee(
tenant_id="tenant-a",
employee_id="manager-a",
employee_no="A-MGR",
name="甲方经理",
email="manager-a@example.com",
),
_employee(
tenant_id="tenant-a",
employee_id="employee-a",
employee_no="A001",
name="甲方员工",
email="employee-a@example.com",
manager_id="manager-a",
),
_employee(
tenant_id="tenant-b",
employee_id="employee-b",
employee_no="B001",
name="乙方员工",
email="employee-b@example.com",
),
]
)
db.add(
_claim(
tenant_id="tenant-b",
claim_id="claim-b",
claim_no="RE-B-001",
employee_id="employee-b",
employee_name="乙方员工",
department_id="dept-b",
department_name="乙方部门",
project_code="PROJECT-B",
)
)
db.commit()
current = {
"user": CurrentUserContext(
username="manager-a@example.com",
name="甲方经理",
role_codes=["manager"],
is_admin=False,
tenant_id="tenant-a",
employee_id="manager-a",
)
}
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_current_user] = lambda: current["user"]
client = TestClient(app)
assert client.get("/api/v1/employee-profiles/employee-a/latest").status_code == 200
assert client.get("/api/v1/employee-profiles/employee-b/latest").status_code == 404
assert (
client.get(
"/api/v1/employee-profiles/employee-a/latest",
params={"claim_id": "claim-b"},
).status_code
== 404
)
current["user"] = CurrentUserContext(
username="employee-a@example.com",
name="甲方员工",
role_codes=[],
is_admin=False,
tenant_id="tenant-a",
employee_id="employee-a",
)
assert client.get("/api/v1/employee-profiles/manager-a/latest").status_code == 404
assert client.get("/api/v1/employee-profiles/employee-a/latest").status_code == 200
def test_orchestrator_rejects_untrusted_or_mismatched_tenant_before_run() -> None:
factory = _session_factory()
payload = OrchestratorRequest(
source="user_message",
user_id="employee-a@example.com",
message="查询本月报销金额",
context_json={"tenant_id": "forged-tenant"},
)
with factory() as db:
db.add(_tenant("tenant-a"))
db.add(
Tenant(
tenant_id="tenant-suspended",
tenant_code="tenant-suspended",
name="已停用公司",
status="suspended",
)
)
db.commit()
service = OrchestratorService(db)
with pytest.raises(ValueError, match="缺少可信租户上下文"):
service.run(payload)
with pytest.raises(ValueError, match="不存在或未启用"):
service.run(payload, trusted_tenant_id="unknown-tenant")
with pytest.raises(ValueError, match="不存在或未启用"):
service.run(payload, trusted_tenant_id="tenant-suspended")
current_user = CurrentUserContext(
username="employee-a@example.com",
name="甲方员工",
role_codes=[],
is_admin=False,
tenant_id="tenant-a",
employee_id="employee-a",
)
with pytest.raises(ValueError, match="租户不一致"):
service.run(
payload,
current_user=current_user,
trusted_tenant_id="tenant-b",
)
assert db.scalar(select(AgentRun.id)) is None