from __future__ import annotations from collections.abc import Generator import pytest from fastapi import FastAPI 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, get_optional_current_user, ) from app.db.base import Base from app.main import create_app from app.models.agent_conversation import AgentConversation from app.schemas.orchestrator import ( OrchestratorRequest, OrchestratorResponse, OrchestratorTraceSummary, ) from app.services.orchestrator import OrchestratorService @pytest.fixture def http_context() -> Generator[ tuple[TestClient, FastAPI, sessionmaker[Session]], None, None, ]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() def override_db() -> Generator[Session, None, None]: with session_factory() as db: yield db app.dependency_overrides[get_db] = override_db client = TestClient(app) try: yield client, app, session_factory finally: client.close() app.dependency_overrides.clear() engine.dispose() def authenticated_user() -> CurrentUserContext: return CurrentUserContext( username="signed-in@example.com", name="登录用户", role_codes=["user"], is_admin=False, tenant_id="tenant-signed-in", department_name="财务共享部", cost_center="CC-SIGNED-IN", position="费用专员", grade="P5", employee_no="E-SIGNED-IN", manager_name="直属经理", employee_id="employee-signed-in", auth_session_id="session-signed-in", ) def install_authenticated_user(app: FastAPI) -> CurrentUserContext: current_user = authenticated_user() app.dependency_overrides[get_current_user] = lambda: current_user app.dependency_overrides[get_optional_current_user] = lambda: current_user return current_user def install_admin_user(app: FastAPI) -> CurrentUserContext: current_user = authenticated_user() current_user.role_codes = ["admin"] current_user.is_admin = True app.dependency_overrides[get_current_user] = lambda: current_user app.dependency_overrides[get_optional_current_user] = lambda: current_user return current_user @pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"]) def test_orchestrator_run_requires_authentication_for_every_external_source( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], source: str, ) -> None: client, _, _ = http_context response = client.post( "/api/v1/orchestrator/run", json={ "source": source, "user_id": "forged-user@example.com", "message": "帮我申请差旅费用", "context_json": {}, }, ) assert response.status_code == 401 assert response.headers["www-authenticate"] == "Bearer" assert response.json()["detail"] == "请先登录后再使用智能助手。" @pytest.mark.parametrize("source", ["system_event", "schedule"]) def test_non_admin_cannot_trigger_privileged_orchestrator_sources( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], source: str, ) -> None: client, app, _ = http_context install_authenticated_user(app) response = client.post( "/api/v1/orchestrator/run", json={ "source": source, "user_id": "forged-admin@example.com", "message": "运行全局风险扫描", "context_json": { "role_codes": ["admin"], "is_admin": True, }, }, ) assert response.status_code == 403 assert response.json()["detail"] == "只有平台管理员可以从外部触发调度或系统事件。" @pytest.mark.parametrize("source", ["system_event", "schedule"]) def test_admin_can_trigger_privileged_orchestrator_sources( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], monkeypatch: pytest.MonkeyPatch, source: str, ) -> None: client, app, _ = http_context expected_user = install_admin_user(app) captured: dict[str, object] = {} def fake_run( self: OrchestratorService, payload: OrchestratorRequest, *, current_user: CurrentUserContext | None = None, ) -> OrchestratorResponse: del self captured["source"] = payload.source captured["current_user"] = current_user return OrchestratorResponse( run_id="run-admin-source", selected_agent="hermes", route_reason="test_admin_source", permission_level="read", status="succeeded", result={}, requires_confirmation=False, trace_summary=OrchestratorTraceSummary( scenario="system", intent="risk_check", ), ) monkeypatch.setattr(OrchestratorService, "run", fake_run) response = client.post( "/api/v1/orchestrator/run", json={ "source": source, "message": "运行全局风险扫描", "context_json": {}, }, ) assert response.status_code == 200, response.text assert captured["source"] == source assert captured["current_user"] == expected_user @pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"]) def test_authenticated_payload_overrides_all_scheduler_identity_aliases( source: str, ) -> None: current_user = authenticated_user() payload = OrchestratorRequest( source=source, user_id="forged-user@example.com", message="测试身份覆盖", context_json={ "username": "forged-user@example.com", "user_id": "forged-user@example.com", "tenant_id": "tenant-forged", "role_codes": ["admin"], "is_admin": True, "requested_by_username": "forged-requester@example.com", "requested_by_name": "伪造请求人", "actor": "forged-actor@example.com", "actor_id": "forged-actor-id", "auth_session_id": "forged-session", }, ) trusted = OrchestratorService._build_authenticated_user_payload(payload, current_user) assert trusted.user_id == current_user.username assert trusted.context_json["tenant_id"] == current_user.tenant_id assert trusted.context_json["role_codes"] == current_user.role_codes assert trusted.context_json["is_admin"] is current_user.is_admin assert trusted.context_json["requested_by_username"] == current_user.username assert trusted.context_json["requested_by_name"] == current_user.name assert trusted.context_json["actor"] == current_user.username assert trusted.context_json["actor_id"] == current_user.username assert "auth_session_id" not in trusted.context_json def test_authenticated_run_passes_server_identity_to_orchestrator( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], monkeypatch: pytest.MonkeyPatch, ) -> None: client, app, _ = http_context expected_user = install_authenticated_user(app) captured: dict[str, object] = {} def fake_run( self: OrchestratorService, payload: OrchestratorRequest, *, current_user: CurrentUserContext | None = None, ) -> OrchestratorResponse: del self captured["payload"] = payload captured["current_user"] = current_user return OrchestratorResponse( run_id="run-auth-binding", conversation_id="conversation-auth-binding", selected_agent="user_agent", route_reason="test", permission_level="user", status="succeeded", result={}, requires_confirmation=False, trace_summary=OrchestratorTraceSummary( scenario="expense", intent="operate", ), ) monkeypatch.setattr(OrchestratorService, "run", fake_run) response = client.post( "/api/v1/orchestrator/run", json={ "source": "user_message", "user_id": "forged-user@example.com", "message": "保存申请草稿", "context_json": { "username": "forged-user@example.com", "tenant_id": "tenant-forged", "role_codes": ["admin"], "is_admin": True, "employee_no": "E-FORGED", "auth_session_id": "session-forged", }, }, ) assert response.status_code == 200, response.text assert captured["current_user"] == expected_user assert captured["payload"].user_id == "forged-user@example.com" assert captured["payload"].context_json["tenant_id"] == "tenant-forged" def test_latest_conversation_uses_authenticated_username( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], ) -> None: client, app, session_factory = http_context current_user = install_authenticated_user(app) with session_factory() as db: db.add_all( [ AgentConversation( conversation_id="conversation-signed-in", user_id=current_user.username, source="user_message", title="登录用户会话", state_json={ "tenant_id": current_user.tenant_id, "session_type": "expense", }, ), AgentConversation( conversation_id="conversation-same-user-other-tenant", user_id=current_user.username, source="user_message", title="其他租户同名用户会话", state_json={ "tenant_id": "tenant-other", "session_type": "expense", "application_preview_decision": { "decision_id": "decision-other-tenant", "application_preview": { "fields": {"reason": "其他租户敏感项目"} }, }, }, ), AgentConversation( conversation_id="conversation-forged-user", user_id="forged-user@example.com", source="user_message", title="伪造用户会话", state_json={ "tenant_id": "tenant-forged", "session_type": "expense", }, ), ] ) db.commit() response = client.get( "/api/v1/orchestrator/conversations/latest", params={"user_id": "forged-user@example.com", "session_type": "expense"}, ) assert response.status_code == 200, response.text payload = response.json() assert payload["found"] is True assert payload["conversation"]["conversation_id"] == "conversation-signed-in" assert payload["conversation"]["user_id"] == current_user.username def test_delete_conversations_uses_authenticated_username( http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], ) -> None: client, app, session_factory = http_context current_user = install_authenticated_user(app) with session_factory() as db: db.add_all( [ AgentConversation( conversation_id="conversation-owner-single", user_id=current_user.username, source="user_message", state_json={ "tenant_id": current_user.tenant_id, "session_type": "expense", }, ), AgentConversation( conversation_id="conversation-owner-bulk", user_id=current_user.username, source="user_message", state_json={ "tenant_id": current_user.tenant_id, "session_type": "expense", }, ), AgentConversation( conversation_id="conversation-same-user-other-tenant", user_id=current_user.username, source="user_message", state_json={ "tenant_id": "tenant-other", "session_type": "expense", }, ), AgentConversation( conversation_id="conversation-attacker", user_id="forged-user@example.com", source="user_message", state_json={ "tenant_id": "tenant-forged", "session_type": "expense", }, ), ] ) db.commit() single_response = client.delete( "/api/v1/orchestrator/conversations/conversation-owner-single", params={"user_id": "forged-user@example.com"}, ) bulk_response = client.delete( "/api/v1/orchestrator/conversations", params={"user_id": "forged-user@example.com", "session_type": "expense"}, ) assert single_response.status_code == 200, single_response.text assert single_response.json()["deleted_count"] == 1 assert bulk_response.status_code == 200, bulk_response.text assert bulk_response.json()["deleted_count"] == 1 with session_factory() as db: remaining = list(db.scalars(select(AgentConversation)).all()) assert {item.conversation_id for item in remaining} == { "conversation-attacker", "conversation-same-user-other-tenant", }