feat(ai): unify verified expense application workflow
This commit is contained in:
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
@@ -0,0 +1,407 @@
|
||||
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",
|
||||
}
|
||||
Reference in New Issue
Block a user