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