Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
408 lines
14 KiB
Python
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
|