Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
368 lines
13 KiB
Python
368 lines
13 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 select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||
from app.api.v1.endpoints import steward as steward_endpoint
|
||
from app.main import create_app
|
||
from app.models.agent_conversation import AgentConversation
|
||
from app.models.financial_record import ExpenseClaim
|
||
from app.schemas.steward import (
|
||
StewardPlanResponse,
|
||
StewardRuntimeDecisionResponse,
|
||
StewardSlotDecisionResponse,
|
||
)
|
||
from app.services.expense_cases import ExpenseCaseService
|
||
from app.test_helpers.db import build_in_memory_session_factory
|
||
|
||
|
||
def _current_user(*, tenant_id: str = "tenant-a") -> CurrentUserContext:
|
||
return CurrentUserContext(
|
||
username="trusted.user@example.com",
|
||
name="可信用户",
|
||
role_codes=["user"],
|
||
is_admin=False,
|
||
tenant_id=tenant_id,
|
||
department_name="交付部",
|
||
department_id="dept-delivery",
|
||
cost_center="CC-TRUSTED",
|
||
position="实施顾问",
|
||
grade="P6",
|
||
employee_no="E-TRUSTED",
|
||
employee_id="employee-trusted",
|
||
manager_name="可信经理",
|
||
auth_session_id="session-secret",
|
||
)
|
||
|
||
|
||
def _build_client(
|
||
*,
|
||
current_user: CurrentUserContext | None,
|
||
) -> tuple[TestClient, object, object]:
|
||
session_factory = build_in_memory_session_factory()
|
||
app = create_app()
|
||
|
||
def override_db() -> Generator[Session, None, None]:
|
||
db = session_factory()
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
if current_user is not None:
|
||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||
return TestClient(app), session_factory, app
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("path", "payload"),
|
||
[
|
||
("/api/v1/steward/plans", {"message": "报销交通费"}),
|
||
("/api/v1/steward/plans/stream", {"message": "报销交通费"}),
|
||
(
|
||
"/api/v1/steward/slot-decisions",
|
||
{"task_type": "reimbursement", "user_message": "报销交通费"},
|
||
),
|
||
(
|
||
"/api/v1/steward/runtime-decisions",
|
||
{"user_message": "继续处理"},
|
||
),
|
||
],
|
||
)
|
||
def test_steward_ai_endpoints_require_authenticated_user(
|
||
path: str,
|
||
payload: dict[str, object],
|
||
) -> None:
|
||
client, _session_factory, app = _build_client(current_user=None)
|
||
try:
|
||
response = client.post(path, json=payload)
|
||
assert response.status_code == 401
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("path", "payload"),
|
||
[
|
||
("/api/v1/steward/plans", {"message": "报销交通费"}),
|
||
("/api/v1/steward/plans/stream", {"message": "报销交通费"}),
|
||
(
|
||
"/api/v1/steward/slot-decisions",
|
||
{"task_type": "reimbursement", "user_message": "报销交通费"},
|
||
),
|
||
(
|
||
"/api/v1/steward/runtime-decisions",
|
||
{"user_message": "继续处理"},
|
||
),
|
||
],
|
||
)
|
||
def test_steward_ai_endpoints_fail_closed_without_tenant(
|
||
path: str,
|
||
payload: dict[str, object],
|
||
) -> None:
|
||
client, _session_factory, app = _build_client(
|
||
current_user=_current_user(tenant_id=""),
|
||
)
|
||
try:
|
||
response = client.post(path, json=payload)
|
||
assert response.status_code == 403
|
||
assert "缺少租户归属" in response.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_steward_plan_and_stream_use_authenticated_identity(monkeypatch) -> None:
|
||
captured_payloads = []
|
||
|
||
class CapturingPlanner:
|
||
@staticmethod
|
||
def _clean_text(value):
|
||
return str(value or "").strip()
|
||
|
||
@staticmethod
|
||
def _resolve_base_date(_client_now_iso, _context_json):
|
||
return datetime(2026, 7, 16, tzinfo=UTC).date()
|
||
|
||
@staticmethod
|
||
def _looks_like_ambiguous_travel_flow(_message, _base_date, _payload):
|
||
return False
|
||
|
||
@staticmethod
|
||
def build_plan(payload):
|
||
captured_payloads.append(payload)
|
||
return StewardPlanResponse(
|
||
plan_id=f"plan-auth-{len(captured_payloads)}",
|
||
summary="已按可信登录身份生成计划。",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
steward_endpoint,
|
||
"_build_steward_planner",
|
||
lambda _db: CapturingPlanner(),
|
||
)
|
||
current_user = _current_user()
|
||
client, session_factory, app = _build_client(current_user=current_user)
|
||
forged_context = {
|
||
"tenant_id": "tenant-forged",
|
||
"tenantId": "tenant-forged-camel",
|
||
"user_id": "forged-user",
|
||
"userId": "forged-user-camel",
|
||
"username": "forged@example.com",
|
||
"role_codes": ["admin"],
|
||
"is_admin": True,
|
||
"auth_session_id": "forged-session",
|
||
"session_type": "steward",
|
||
}
|
||
try:
|
||
plan_response = client.post(
|
||
"/api/v1/steward/plans",
|
||
json={
|
||
"message": "报销交通费",
|
||
"user_id": "forged-user",
|
||
"context_json": forged_context,
|
||
},
|
||
)
|
||
assert plan_response.status_code == 200
|
||
|
||
with client.stream(
|
||
"POST",
|
||
"/api/v1/steward/plans/stream",
|
||
json={
|
||
"message": "继续报销交通费",
|
||
"user_id": "forged-user",
|
||
"context_json": forged_context,
|
||
},
|
||
) as stream_response:
|
||
assert stream_response.status_code == 200
|
||
assert any(stream_response.iter_lines())
|
||
|
||
assert len(captured_payloads) == 2
|
||
for captured in captured_payloads:
|
||
assert captured.user_id == current_user.username
|
||
assert captured.context_json["tenant_id"] == "tenant-a"
|
||
assert captured.context_json["user_id"] == current_user.username
|
||
assert captured.context_json["username"] == current_user.username
|
||
assert captured.context_json["role_codes"] == ["user"]
|
||
assert captured.context_json["is_admin"] is False
|
||
assert "tenantId" not in captured.context_json
|
||
assert "userId" not in captured.context_json
|
||
assert "auth_session_id" not in captured.context_json
|
||
|
||
with session_factory() as db:
|
||
conversations = list(db.scalars(select(AgentConversation)).all())
|
||
assert len(conversations) == 2
|
||
assert all(item.user_id == current_user.username for item in conversations)
|
||
assert all(
|
||
item.state_json.get("tenant_id") == "tenant-a"
|
||
for item in conversations
|
||
)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_steward_slot_and_runtime_ignore_forged_identity(monkeypatch) -> None:
|
||
captured_slot_payloads = []
|
||
captured_runtime_payloads = []
|
||
|
||
def fake_slot_decision(payload, _runtime_chat):
|
||
captured_slot_payloads.append(payload)
|
||
return StewardSlotDecisionResponse(
|
||
next_action="ask_user",
|
||
question="请补充金额。",
|
||
)
|
||
|
||
def fake_runtime_decision(payload, _runtime_chat):
|
||
captured_runtime_payloads.append(payload)
|
||
return StewardRuntimeDecisionResponse(next_action="no_op")
|
||
|
||
monkeypatch.setattr(steward_endpoint, "_decide_steward_slot", fake_slot_decision)
|
||
monkeypatch.setattr(
|
||
steward_endpoint,
|
||
"_decide_steward_runtime",
|
||
fake_runtime_decision,
|
||
)
|
||
client, _session_factory, app = _build_client(current_user=_current_user())
|
||
try:
|
||
slot_response = client.post(
|
||
"/api/v1/steward/slot-decisions",
|
||
json={
|
||
"task_type": "reimbursement",
|
||
"user_message": "报销交通费",
|
||
"task_context": {
|
||
"tenant_id": "tenant-forged",
|
||
"tenantId": "tenant-forged-camel",
|
||
"username": "forged@example.com",
|
||
"is_admin": True,
|
||
},
|
||
},
|
||
)
|
||
runtime_response = client.post(
|
||
"/api/v1/steward/runtime-decisions",
|
||
json={
|
||
"user_message": "继续处理",
|
||
"context_json": {
|
||
"tenant_id": "tenant-forged",
|
||
"tenantId": "tenant-forged-camel",
|
||
"username": "forged@example.com",
|
||
},
|
||
"runtime_state": {
|
||
"tenant_id": "tenant-forged-runtime",
|
||
"user_id": "forged-runtime-user",
|
||
},
|
||
},
|
||
)
|
||
|
||
assert slot_response.status_code == 200
|
||
assert runtime_response.status_code == 200
|
||
assert len(captured_slot_payloads) == 1
|
||
assert len(captured_runtime_payloads) == 1
|
||
slot_context = captured_slot_payloads[0].task_context
|
||
assert slot_context["tenant_id"] == "tenant-a"
|
||
assert slot_context["username"] == "trusted.user@example.com"
|
||
assert slot_context["is_admin"] is False
|
||
assert "tenantId" not in slot_context
|
||
|
||
runtime_payload = captured_runtime_payloads[0]
|
||
assert runtime_payload.context_json["tenant_id"] == "tenant-a"
|
||
assert runtime_payload.runtime_state["tenant_id"] == "tenant-a"
|
||
assert runtime_payload.context_json["user_id"] == "trusted.user@example.com"
|
||
assert runtime_payload.runtime_state["user_id"] == "trusted.user@example.com"
|
||
assert "tenantId" not in runtime_payload.context_json
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_steward_runtime_rejects_cross_tenant_conversation(monkeypatch) -> None:
|
||
client, session_factory, app = _build_client(current_user=_current_user())
|
||
with session_factory() as db:
|
||
db.add(
|
||
AgentConversation(
|
||
conversation_id="conv-tenant-b",
|
||
user_id="trusted.user@example.com",
|
||
source="user_message",
|
||
state_json={
|
||
"tenant_id": "tenant-b",
|
||
"session_type": "steward",
|
||
"steward_state": {"active_flow": "travel_reimbursement"},
|
||
},
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
monkeypatch.setattr(
|
||
steward_endpoint,
|
||
"_decide_steward_runtime",
|
||
lambda *_args, **_kwargs: pytest.fail("越权会话不应进入决策服务"),
|
||
)
|
||
try:
|
||
response = client.post(
|
||
"/api/v1/steward/runtime-decisions",
|
||
json={
|
||
"user_message": "继续处理",
|
||
"context_json": {"conversation_id": "conv-tenant-b"},
|
||
},
|
||
)
|
||
assert response.status_code == 403
|
||
assert "不属于登录用户或租户" in response.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_steward_application_candidates_are_tenant_scoped() -> None:
|
||
_client, session_factory, app = _build_client(current_user=_current_user())
|
||
try:
|
||
with session_factory() as db:
|
||
for tenant_id, suffix in (("tenant-a", "A"), ("tenant-b", "B")):
|
||
claim = ExpenseClaim(
|
||
id=f"application-tenant-{suffix.lower()}",
|
||
tenant_id=tenant_id,
|
||
claim_no=f"AP-TENANT-{suffix}",
|
||
employee_id="employee-trusted",
|
||
employee_name="可信用户",
|
||
department_name="交付部",
|
||
expense_type="travel_application",
|
||
reason="上海客户现场部署",
|
||
location="上海",
|
||
amount=Decimal("1200.00"),
|
||
currency="CNY",
|
||
invoice_count=0,
|
||
occurred_at=datetime(2026, 7, 20, tzinfo=UTC),
|
||
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||
status="approved",
|
||
approval_stage="已完成",
|
||
risk_flags_json=[],
|
||
)
|
||
db.add(claim)
|
||
db.flush()
|
||
ExpenseCaseService(db).ensure_case_for_claim(
|
||
claim,
|
||
tenant_id=tenant_id,
|
||
)
|
||
db.commit()
|
||
|
||
payload = steward_endpoint._bind_authenticated_plan_request(
|
||
steward_endpoint.StewardPlanRequest(
|
||
message="7月20日去上海出差,继续发起报销",
|
||
context_json={},
|
||
),
|
||
_current_user(),
|
||
)
|
||
candidates = steward_endpoint._query_required_application_gate_candidates(
|
||
db,
|
||
payload,
|
||
payload.context_json,
|
||
tenant_id="tenant-a",
|
||
)
|
||
|
||
assert [item["claim_no"] for item in candidates] == ["AP-TENANT-A"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|