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()