2025-12-18 02:20:53 +08:00
|
|
|
|
"""
|
|
|
|
|
|
API Pipeline 测试
|
|
|
|
|
|
|
|
|
|
|
|
测试 ApiRequestPipeline 的核心功能:
|
|
|
|
|
|
- 认证流程(API Key、JWT Token)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
- 余额计算
|
2025-12-18 02:20:53 +08:00
|
|
|
|
- 审计日志记录
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
2026-03-17 16:34:09 +08:00
|
|
|
|
from types import SimpleNamespace
|
2026-03-09 22:57:23 +08:00
|
|
|
|
from typing import Any
|
2025-12-18 02:20:53 +08:00
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
import pytest
|
2025-12-18 02:20:53 +08:00
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
from src.api.base.adapter import ApiMode
|
2025-12-18 02:20:53 +08:00
|
|
|
|
from src.api.base.pipeline import ApiRequestPipeline
|
2026-01-10 18:43:53 +08:00
|
|
|
|
from src.core.enums import UserRole
|
2026-03-09 22:57:23 +08:00
|
|
|
|
from src.core.modules.hooks import AUTH_TOKEN_PREFIX_AUTHENTICATORS
|
2026-03-15 14:22:59 +08:00
|
|
|
|
from src.services.rate_limit.user_rpm_limiter import RpmCheckResult
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
class TestPipelineBalanceCalculation:
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"""Balance calculation tests for Pipeline."""
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_calculate_balance_remaining_with_balance(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Returns remaining balance for limited wallets."""
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_user = MagicMock()
|
2026-03-09 22:57:23 +08:00
|
|
|
|
mock_user.id = "user-123"
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
thread_db = MagicMock()
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
thread_db.query.return_value.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
|
|
|
|
|
|
with patch("src.api.base.pipeline.create_session", return_value=thread_db):
|
|
|
|
|
|
with patch(
|
|
|
|
|
|
"src.api.base.pipeline.WalletService.get_balance_snapshot",
|
|
|
|
|
|
return_value=70.0,
|
|
|
|
|
|
):
|
|
|
|
|
|
remaining = await pipeline._calculate_balance_remaining_async(mock_user)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert remaining == 70.0
|
2026-03-09 22:57:23 +08:00
|
|
|
|
thread_db.close.assert_called_once()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_calculate_balance_remaining_unlimited(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Returns None for unlimited wallets."""
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_user = MagicMock()
|
2026-03-09 22:57:23 +08:00
|
|
|
|
mock_user.id = "user-123"
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
thread_db = MagicMock()
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
thread_db.query.return_value.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
|
|
|
|
|
|
with patch("src.api.base.pipeline.create_session", return_value=thread_db):
|
|
|
|
|
|
with patch(
|
|
|
|
|
|
"src.api.base.pipeline.WalletService.get_balance_snapshot",
|
|
|
|
|
|
return_value=None,
|
|
|
|
|
|
):
|
|
|
|
|
|
remaining = await pipeline._calculate_balance_remaining_async(mock_user)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert remaining is None
|
2026-03-09 22:57:23 +08:00
|
|
|
|
thread_db.close.assert_called_once()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_calculate_balance_remaining_none_user(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Returns None when user is missing."""
|
|
|
|
|
|
remaining = await pipeline._calculate_balance_remaining_async(None)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert remaining is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
class TestPipelineRunModes:
|
|
|
|
|
|
"""Returns remaining balance for limited wallets."""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_run_management_mode_skips_balance_calculation(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Management mode skips balance calculation."""
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.method = "GET"
|
|
|
|
|
|
mock_request.url.path = "/api/admin/tokens"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_token = MagicMock()
|
|
|
|
|
|
mock_token.id = "mt-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "test-adapter"
|
|
|
|
|
|
mock_adapter.authorize = MagicMock(return_value=None)
|
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
|
mock_response.status_code = 200
|
|
|
|
|
|
mock_adapter.handle = AsyncMock(return_value=mock_response)
|
|
|
|
|
|
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = mock_db
|
|
|
|
|
|
mock_context.request = mock_request
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline,
|
|
|
|
|
|
"_authenticate_management",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value=(mock_user, mock_token),
|
|
|
|
|
|
):
|
|
|
|
|
|
with patch(
|
|
|
|
|
|
"src.api.base.pipeline.ApiRequestContext.build",
|
|
|
|
|
|
return_value=mock_context,
|
|
|
|
|
|
):
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline,
|
|
|
|
|
|
"_calculate_balance_remaining_async",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
) as mock_balance:
|
|
|
|
|
|
with patch.object(pipeline, "_record_audit_event"):
|
|
|
|
|
|
response = await pipeline.run(
|
|
|
|
|
|
mock_adapter,
|
|
|
|
|
|
mock_request,
|
|
|
|
|
|
mock_db,
|
|
|
|
|
|
mode=ApiMode.MANAGEMENT,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert response == mock_response
|
|
|
|
|
|
assert mock_context.management_token == mock_token
|
|
|
|
|
|
mock_balance.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-18 02:20:53 +08:00
|
|
|
|
class TestPipelineAuditLogging:
|
|
|
|
|
|
"""测试 Pipeline 审计日志"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_audit_event_success(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试记录成功的审计事件"""
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = MagicMock()
|
|
|
|
|
|
mock_context.user = MagicMock()
|
|
|
|
|
|
mock_context.user.id = "user-123"
|
|
|
|
|
|
mock_context.api_key = MagicMock()
|
|
|
|
|
|
mock_context.api_key.id = "key-123"
|
|
|
|
|
|
mock_context.request_id = "req-123"
|
|
|
|
|
|
mock_context.client_ip = "127.0.0.1"
|
|
|
|
|
|
mock_context.user_agent = "test-agent"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.request_method = "POST"
|
|
|
|
|
|
mock_context.request_path = "/v1/messages"
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
|
|
|
|
|
mock_context.gateway_execution_path = None
|
|
|
|
|
|
mock_context.rate_limit_scope = None
|
|
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_context.request = MagicMock()
|
|
|
|
|
|
mock_context.request.method = "POST"
|
|
|
|
|
|
mock_context.request.url.path = "/v1/messages"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.original_headers = {}
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_context.start_time = 1000.0
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "test-adapter"
|
|
|
|
|
|
mock_adapter.audit_log_enabled = True
|
|
|
|
|
|
mock_adapter.audit_success_event = None
|
|
|
|
|
|
mock_adapter.audit_failure_event = None
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.audit_service,
|
|
|
|
|
|
"log_event",
|
|
|
|
|
|
) as mock_log:
|
|
|
|
|
|
with patch("time.time", return_value=1001.0):
|
|
|
|
|
|
pipeline._record_audit_event(
|
|
|
|
|
|
mock_context, mock_adapter, success=True, status_code=200
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
mock_log.assert_called_once()
|
|
|
|
|
|
call_kwargs = mock_log.call_args[1]
|
|
|
|
|
|
assert call_kwargs["user_id"] == "user-123"
|
|
|
|
|
|
assert call_kwargs["status_code"] == 200
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_audit_event_failure(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试记录失败的审计事件"""
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = MagicMock()
|
|
|
|
|
|
mock_context.user = MagicMock()
|
|
|
|
|
|
mock_context.user.id = "user-123"
|
|
|
|
|
|
mock_context.api_key = MagicMock()
|
|
|
|
|
|
mock_context.api_key.id = "key-123"
|
|
|
|
|
|
mock_context.request_id = "req-123"
|
|
|
|
|
|
mock_context.client_ip = "127.0.0.1"
|
|
|
|
|
|
mock_context.user_agent = "test-agent"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.request_method = "POST"
|
|
|
|
|
|
mock_context.request_path = "/v1/messages"
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
|
|
|
|
|
mock_context.gateway_execution_path = None
|
|
|
|
|
|
mock_context.rate_limit_scope = None
|
|
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_context.request = MagicMock()
|
|
|
|
|
|
mock_context.request.method = "POST"
|
|
|
|
|
|
mock_context.request.url.path = "/v1/messages"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.original_headers = {}
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_context.start_time = 1000.0
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "test-adapter"
|
|
|
|
|
|
mock_adapter.audit_log_enabled = True
|
|
|
|
|
|
mock_adapter.audit_success_event = None
|
|
|
|
|
|
mock_adapter.audit_failure_event = None
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.audit_service,
|
|
|
|
|
|
"log_event",
|
|
|
|
|
|
) as mock_log:
|
|
|
|
|
|
with patch("time.time", return_value=1001.0):
|
|
|
|
|
|
pipeline._record_audit_event(
|
2026-02-01 17:28:00 +08:00
|
|
|
|
mock_context,
|
|
|
|
|
|
mock_adapter,
|
|
|
|
|
|
success=False,
|
|
|
|
|
|
status_code=500,
|
|
|
|
|
|
error="Internal error",
|
2025-12-18 02:20:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
mock_log.assert_called_once()
|
|
|
|
|
|
call_kwargs = mock_log.call_args[1]
|
|
|
|
|
|
assert call_kwargs["status_code"] == 500
|
|
|
|
|
|
assert call_kwargs["error_message"] == "Internal error"
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
def test_record_audit_event_commits_when_route_already_committed(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = MagicMock()
|
|
|
|
|
|
mock_context.user = MagicMock()
|
|
|
|
|
|
mock_context.user.id = "user-123"
|
|
|
|
|
|
mock_context.api_key = None
|
|
|
|
|
|
mock_context.request_id = "req-123"
|
|
|
|
|
|
mock_context.client_ip = "127.0.0.1"
|
|
|
|
|
|
mock_context.user_agent = "test-agent"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.request_method = "POST"
|
|
|
|
|
|
mock_context.request_path = "/api/auth/refresh"
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
|
|
|
|
|
mock_context.gateway_execution_path = None
|
|
|
|
|
|
mock_context.rate_limit_scope = None
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_context.request = MagicMock()
|
|
|
|
|
|
mock_context.request.method = "POST"
|
|
|
|
|
|
mock_context.request.url.path = "/api/auth/refresh"
|
|
|
|
|
|
mock_context.request.state = SimpleNamespace(tx_committed_by_route=True)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.original_headers = {}
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_context.start_time = 1000.0
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock(
|
|
|
|
|
|
side_effect=lambda: setattr(mock_context, "tx_committed_by_route", True)
|
|
|
|
|
|
)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "test-adapter"
|
|
|
|
|
|
mock_adapter.audit_log_enabled = True
|
|
|
|
|
|
mock_adapter.audit_success_event = None
|
|
|
|
|
|
mock_adapter.audit_failure_event = None
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(pipeline.audit_service, "log_event") as mock_log:
|
|
|
|
|
|
pipeline._record_audit_event(mock_context, mock_adapter, success=True, status_code=200)
|
|
|
|
|
|
|
|
|
|
|
|
mock_log.assert_called_once()
|
|
|
|
|
|
mock_context.db.commit.assert_called_once()
|
|
|
|
|
|
|
2025-12-18 02:20:53 +08:00
|
|
|
|
def test_record_audit_event_no_db(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试没有数据库会话时跳过审计"""
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = None
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock()
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.audit_log_enabled = True
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.audit_service,
|
|
|
|
|
|
"log_event",
|
|
|
|
|
|
) as mock_log:
|
|
|
|
|
|
# 不应该抛出异常
|
|
|
|
|
|
pipeline._record_audit_event(mock_context, mock_adapter, success=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 不应该调用 log_event
|
|
|
|
|
|
mock_log.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_audit_event_disabled(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试审计日志被禁用时跳过"""
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.audit_log_enabled = False
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.audit_service,
|
|
|
|
|
|
"log_event",
|
|
|
|
|
|
) as mock_log:
|
|
|
|
|
|
pipeline._record_audit_event(mock_context, mock_adapter, success=True)
|
|
|
|
|
|
|
|
|
|
|
|
mock_log.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_audit_event_exception_handling(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试审计日志异常不影响主流程"""
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.db = MagicMock()
|
|
|
|
|
|
mock_context.user = MagicMock()
|
|
|
|
|
|
mock_context.user.id = "user-123"
|
|
|
|
|
|
mock_context.api_key = MagicMock()
|
|
|
|
|
|
mock_context.api_key.id = "key-123"
|
|
|
|
|
|
mock_context.request_id = "req-123"
|
|
|
|
|
|
mock_context.client_ip = "127.0.0.1"
|
|
|
|
|
|
mock_context.user_agent = "test-agent"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.request_method = "POST"
|
|
|
|
|
|
mock_context.request_path = "/v1/messages"
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
|
|
|
|
|
mock_context.gateway_execution_path = None
|
|
|
|
|
|
mock_context.rate_limit_scope = None
|
|
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_context.request = MagicMock()
|
|
|
|
|
|
mock_context.request.method = "POST"
|
|
|
|
|
|
mock_context.request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_context.start_time = 1000.0
|
2026-03-31 19:19:04 +08:00
|
|
|
|
mock_context.original_headers = {}
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "test-adapter"
|
|
|
|
|
|
mock_adapter.audit_log_enabled = True
|
|
|
|
|
|
mock_adapter.audit_success_event = None
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.audit_service,
|
|
|
|
|
|
"log_event",
|
|
|
|
|
|
side_effect=Exception("DB error"),
|
|
|
|
|
|
):
|
|
|
|
|
|
with patch("time.time", return_value=1001.0):
|
|
|
|
|
|
# 不应该抛出异常
|
|
|
|
|
|
pipeline._record_audit_event(mock_context, mock_adapter, success=True)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
def test_build_audit_metadata_prefers_context_path_params(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_context = MagicMock()
|
|
|
|
|
|
mock_context.start_time = 1000.0
|
|
|
|
|
|
mock_context.mode = "standard"
|
|
|
|
|
|
mock_context.api_format_hint = "gemini"
|
|
|
|
|
|
mock_context.query_params = {}
|
|
|
|
|
|
mock_context.raw_body = b"{}"
|
|
|
|
|
|
mock_context.balance_remaining = 12.5
|
|
|
|
|
|
mock_context.audit_metadata = {}
|
|
|
|
|
|
mock_context.quiet_logging = False
|
|
|
|
|
|
mock_context.user = None
|
|
|
|
|
|
mock_context.api_key = None
|
|
|
|
|
|
mock_context.request_method = "POST"
|
|
|
|
|
|
mock_context.request_path = "/v1beta/models/gemini-2.5-flash:streamGenerateContent"
|
|
|
|
|
|
mock_context.tx_committed_by_route = False
|
|
|
|
|
|
mock_context.gateway_execution_path = "executor_local"
|
|
|
|
|
|
mock_context.rate_limit_scope = "user"
|
|
|
|
|
|
mock_context.sync_runtime_state_from_request = MagicMock()
|
|
|
|
|
|
mock_context.path_params = {"model": "gemini-2.5-flash", "stream": True}
|
|
|
|
|
|
mock_context.request = MagicMock()
|
|
|
|
|
|
mock_context.request.method = "POST"
|
|
|
|
|
|
mock_context.request.url.path = "/v1beta/models/gemini-2.5-flash:streamGenerateContent"
|
|
|
|
|
|
mock_context.original_headers = {"content-type": "application/json"}
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.name = "public.gemini.content"
|
|
|
|
|
|
mock_adapter.__class__.__name__ = "PublicGeminiContentAdapter"
|
|
|
|
|
|
mock_adapter.mode.value = "standard"
|
|
|
|
|
|
mock_adapter.get_audit_metadata.return_value = {}
|
|
|
|
|
|
|
|
|
|
|
|
with patch("time.time", return_value=1001.0):
|
|
|
|
|
|
metadata = pipeline._build_audit_metadata(
|
|
|
|
|
|
context=mock_context,
|
|
|
|
|
|
adapter=mock_adapter,
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
status_code=200,
|
|
|
|
|
|
error=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert metadata["path_params"] == {"model": "gemini-2.5-flash", "stream": True}
|
|
|
|
|
|
assert metadata["gateway_execution_path"] == "executor_local"
|
|
|
|
|
|
assert metadata["rate_limit_scope"] == "user"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_apply_context_runtime_state_prefers_snapshot_balance(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
context = MagicMock()
|
|
|
|
|
|
context.prefetched_balance_remaining = 9.5
|
|
|
|
|
|
context.balance_remaining = None
|
|
|
|
|
|
context.management_token = None
|
|
|
|
|
|
context.quiet_logging = False
|
|
|
|
|
|
|
|
|
|
|
|
auth_state = SimpleNamespace(
|
|
|
|
|
|
user=MagicMock(id="user-1"),
|
|
|
|
|
|
api_key=MagicMock(id="key-1"),
|
|
|
|
|
|
management_token=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
pipeline._calculate_balance_remaining_async = AsyncMock(
|
|
|
|
|
|
side_effect=AssertionError("prefetched snapshot should skip balance lookup")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await pipeline._apply_context_runtime_state_legacy(
|
|
|
|
|
|
context,
|
|
|
|
|
|
mode=ApiMode.STANDARD,
|
|
|
|
|
|
auth_state=auth_state,
|
|
|
|
|
|
quiet=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert context.balance_remaining == 9.5
|
|
|
|
|
|
assert context.quiet_logging is True
|
|
|
|
|
|
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
class TestPipelineAuthentication:
|
|
|
|
|
|
"""测试 Pipeline 认证相关逻辑"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_missing_key(self, pipeline: ApiRequestPipeline) -> None:
|
2025-12-18 02:20:53 +08:00
|
|
|
|
"""测试缺少 API Key 时抛出异常"""
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {}
|
|
|
|
|
|
mock_request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
2026-03-09 22:57:23 +08:00
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
|
assert "API密钥" in exc_info.value.detail
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_invalid_key(self, pipeline: ApiRequestPipeline) -> None:
|
2025-12-18 02:20:53 +08:00
|
|
|
|
"""测试无效的 API Key"""
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"Authorization": "Bearer sk-invalid"}
|
|
|
|
|
|
mock_request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value="sk-invalid")
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
2025-12-18 02:20:53 +08:00
|
|
|
|
return_value=None,
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
2026-03-09 22:57:23 +08:00
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_balance_exceeded(self, pipeline: ApiRequestPipeline) -> None:
|
2026-03-08 00:05:48 +08:00
|
|
|
|
"""测试余额不足时抛出异常"""
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_api_key = MagicMock()
|
|
|
|
|
|
mock_api_key.id = "key-123"
|
|
|
|
|
|
mock_api_key.is_standalone = False
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"Authorization": "Bearer sk-test"}
|
|
|
|
|
|
mock_request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
2026-03-09 22:57:23 +08:00
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = True
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = False
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value="sk-test")
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
2026-03-09 22:57:23 +08:00
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
|
user=mock_user,
|
|
|
|
|
|
api_key=mock_api_key,
|
|
|
|
|
|
access_allowed=False,
|
|
|
|
|
|
balance_remaining=0.0,
|
|
|
|
|
|
),
|
2025-12-18 02:20:53 +08:00
|
|
|
|
):
|
2026-03-09 22:57:23 +08:00
|
|
|
|
from src.core.exceptions import BalanceInsufficientException
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(BalanceInsufficientException):
|
|
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_requery_detects_inactive_user(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_api_key = MagicMock()
|
|
|
|
|
|
mock_api_key.id = "key-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"Authorization": "Bearer sk-test"}
|
|
|
|
|
|
mock_request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value="sk-test")
|
|
|
|
|
|
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = False
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = False
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
|
user=mock_user,
|
|
|
|
|
|
api_key=mock_api_key,
|
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
|
balance_remaining=10.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_requery_detects_locked_key(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_api_key = MagicMock()
|
|
|
|
|
|
mock_api_key.id = "key-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"Authorization": "Bearer sk-test"}
|
|
|
|
|
|
mock_request.url.path = "/v1/messages"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value="sk-test")
|
|
|
|
|
|
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = True
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = True
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
|
user=mock_user,
|
|
|
|
|
|
api_key=mock_api_key,
|
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
|
balance_remaining=10.0,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
assert "锁定" in str(exc_info.value.detail)
|
|
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_reuses_trusted_gateway_auth_context(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"x-aether-gateway": "rust-phase3b",
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
"x-aether-execution-path": "public_proxy_after_executor_miss",
|
2026-03-21 12:57:09 +08:00
|
|
|
|
"x-aether-auth-user-id": "user-123",
|
|
|
|
|
|
"x-aether-auth-api-key-id": "key-123",
|
|
|
|
|
|
"x-aether-auth-balance-remaining": "42.5",
|
|
|
|
|
|
"x-aether-auth-access-allowed": "true",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
|
|
|
|
|
mock_request.url.path = "/v1/chat/completions"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = True
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = False
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
mock_adapter.extract_api_key.side_effect = AssertionError(
|
|
|
|
|
|
"trusted auth should short-circuit"
|
|
|
|
|
|
)
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
side_effect=AssertionError("threadsafe auth should not be called"),
|
|
|
|
|
|
):
|
|
|
|
|
|
user, api_key = await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
|
|
|
|
|
assert user == db_user
|
|
|
|
|
|
assert api_key == db_api_key
|
|
|
|
|
|
assert mock_request.state.user_id == "user-123"
|
|
|
|
|
|
assert mock_request.state.api_key_id == "key-123"
|
|
|
|
|
|
assert mock_request.state.prefetched_balance_remaining == 42.5
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
assert mock_request.state.gateway_execution_path == "public_proxy_after_executor_miss"
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_trusted_gateway_balance_denied(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"x-aether-gateway": "rust-phase3b",
|
|
|
|
|
|
"x-aether-auth-user-id": "user-123",
|
|
|
|
|
|
"x-aether-auth-api-key-id": "key-123",
|
|
|
|
|
|
"x-aether-auth-balance-remaining": "0",
|
|
|
|
|
|
"x-aether-auth-access-allowed": "false",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
|
|
|
|
|
mock_request.url.path = "/v1/chat/completions"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = True
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = False
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
refactor: 拆分 Rust gateway/executor 大文件为模块目录结构,拆分 Python gateway.py 为子模块
Rust 侧:
- executor.rs 拆分为 executor/ 目录 (plan_builders, stream, sync, submission)
- 新增 kiro_stream/, local_finalize/, local_stream/ 模块目录
- 新增 video_tasks.rs
- 测试文件 ai_execute/files/video 拆分为子目录
- handlers/headers/control/constants 扩展支持新模块
Python 侧:
- gateway.py 拆分为 24 个子模块 (routes, shared, contract, chat, cli, video, files, finalize, reporting 等)
- 新增 antigravity/gemini_cli/kiro 的 rust_http 适配层
- upstream_fetcher 增加 Rust sidecar 支持
- executor_plan/candidate/pipeline 适配调整
测试:
- 对应拆分 test_internal_gateway_routes 为子目录
- 新增 rust_http 相关测试
2026-03-23 17:19:15 +08:00
|
|
|
|
mock_adapter.extract_api_key.side_effect = AssertionError(
|
|
|
|
|
|
"trusted auth should short-circuit"
|
|
|
|
|
|
)
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
from src.core.exceptions import BalanceInsufficientException
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(BalanceInsufficientException):
|
|
|
|
|
|
await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_client_gateway_marker_without_trusted_ids_falls_back_to_legacy(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_api_key = MagicMock()
|
|
|
|
|
|
mock_api_key.id = "key-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"x-aether-gateway": "rust-phase3b"}
|
|
|
|
|
|
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
|
|
|
|
|
mock_request.url.path = "/v1/chat/completions"
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
db_user = MagicMock()
|
|
|
|
|
|
db_user.id = "user-123"
|
|
|
|
|
|
db_user.is_active = True
|
|
|
|
|
|
db_user.is_deleted = False
|
|
|
|
|
|
db_api_key = MagicMock()
|
|
|
|
|
|
db_api_key.id = "key-123"
|
|
|
|
|
|
db_api_key.user_id = "user-123"
|
|
|
|
|
|
db_api_key.is_active = True
|
|
|
|
|
|
db_api_key.is_locked = False
|
|
|
|
|
|
db_api_key.is_standalone = False
|
|
|
|
|
|
db_api_key.expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
user_query = MagicMock()
|
|
|
|
|
|
user_query.filter.return_value.first.return_value = db_user
|
|
|
|
|
|
api_key_query = MagicMock()
|
|
|
|
|
|
api_key_query.filter.return_value.first.return_value = db_api_key
|
|
|
|
|
|
mock_db.query.side_effect = [user_query, api_key_query]
|
|
|
|
|
|
|
|
|
|
|
|
mock_adapter = MagicMock()
|
|
|
|
|
|
mock_adapter.extract_api_key = MagicMock(return_value="sk-test")
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"authenticate_api_key_threadsafe",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
|
user=mock_user,
|
|
|
|
|
|
api_key=mock_api_key,
|
|
|
|
|
|
access_allowed=True,
|
|
|
|
|
|
balance_remaining=12.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
) as mock_auth:
|
|
|
|
|
|
user, api_key = await pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
|
|
|
|
|
|
|
|
|
|
|
assert user == db_user
|
|
|
|
|
|
assert api_key == db_api_key
|
|
|
|
|
|
mock_adapter.extract_api_key.assert_called_once_with(mock_request)
|
|
|
|
|
|
mock_auth.assert_awaited_once_with("sk-test")
|
|
|
|
|
|
assert mock_request.state.prefetched_balance_remaining == 12.5
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
class TestPipelineUserRateLimit:
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_apply_legacy_request_guards_skips_when_rust_completed_preflight(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
|
request.headers = {
|
|
|
|
|
|
"x-aether-gateway": "rust-phase3b",
|
|
|
|
|
|
"x-aether-rate-limit-preflight": "true",
|
|
|
|
|
|
}
|
|
|
|
|
|
request.client = SimpleNamespace(host="127.0.0.1")
|
|
|
|
|
|
request.state = MagicMock()
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
user = MagicMock(id="user-1")
|
|
|
|
|
|
api_key = MagicMock(id="key-1")
|
|
|
|
|
|
auth_state = SimpleNamespace(user=user, api_key=api_key, management_token=None)
|
|
|
|
|
|
|
|
|
|
|
|
pipeline._check_user_rate_limit = AsyncMock(
|
|
|
|
|
|
side_effect=AssertionError("trusted rust preflight should skip legacy limiter")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await pipeline._apply_legacy_request_guards(
|
|
|
|
|
|
request,
|
|
|
|
|
|
db,
|
|
|
|
|
|
mode=ApiMode.STANDARD,
|
|
|
|
|
|
auth_state=auth_state,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_check_user_rate_limit_uses_system_default_for_user_scope(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
|
request.state = MagicMock()
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
user = MagicMock(id="user-1", rate_limit=None)
|
|
|
|
|
|
api_key = MagicMock(id="key-1", is_standalone=False, rate_limit=0)
|
|
|
|
|
|
|
|
|
|
|
|
limiter = MagicMock()
|
|
|
|
|
|
limiter.get_user_rpm_key.return_value = "rpm:user:user-1:1"
|
|
|
|
|
|
limiter.get_key_rpm_key.return_value = "rpm:key:key-1:1"
|
|
|
|
|
|
limiter.check_and_consume = AsyncMock(return_value=RpmCheckResult(allowed=True))
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.get_user_rpm_limiter",
|
|
|
|
|
|
AsyncMock(return_value=limiter),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.SystemConfigService.get_config",
|
|
|
|
|
|
lambda *_a, **_k: 60,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await pipeline._check_user_rate_limit(request, db, user, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
limiter.check_and_consume.assert_awaited_once_with(
|
|
|
|
|
|
user_rpm_key="rpm:user:user-1:1",
|
|
|
|
|
|
user_rpm_limit=60,
|
|
|
|
|
|
key_rpm_key="rpm:key:key-1:1",
|
|
|
|
|
|
key_rpm_limit=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_check_user_rate_limit_returns_429_with_scope_header(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
|
request.state = MagicMock()
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
user = MagicMock(id="user-1", rate_limit=100)
|
|
|
|
|
|
api_key = MagicMock(id="key-1", is_standalone=False, rate_limit=10)
|
|
|
|
|
|
|
|
|
|
|
|
limiter = MagicMock()
|
|
|
|
|
|
limiter.get_user_rpm_key.return_value = "rpm:user:user-1:1"
|
|
|
|
|
|
limiter.get_key_rpm_key.return_value = "rpm:key:key-1:1"
|
|
|
|
|
|
limiter.get_retry_after.return_value = 17
|
|
|
|
|
|
limiter.check_and_consume = AsyncMock(
|
|
|
|
|
|
return_value=RpmCheckResult(
|
|
|
|
|
|
allowed=False,
|
|
|
|
|
|
scope="key",
|
|
|
|
|
|
limit=10,
|
|
|
|
|
|
remaining=0,
|
|
|
|
|
|
retry_after=17,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.get_user_rpm_limiter",
|
|
|
|
|
|
AsyncMock(return_value=limiter),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.SystemConfigService.get_config",
|
|
|
|
|
|
lambda *_a, **_k: 60,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._check_user_rate_limit(request, db, user, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 429
|
|
|
|
|
|
assert exc_info.value.headers == {
|
|
|
|
|
|
"Retry-After": "17",
|
|
|
|
|
|
"X-RateLimit-Limit": "10",
|
|
|
|
|
|
"X-RateLimit-Remaining": "0",
|
|
|
|
|
|
"X-RateLimit-Scope": "key",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_check_user_rate_limit_uses_system_default_for_standalone_key(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
|
request.state = MagicMock()
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
user = MagicMock(id="user-1", rate_limit=999)
|
|
|
|
|
|
api_key = MagicMock(id="standalone-1", is_standalone=True, rate_limit=None)
|
|
|
|
|
|
|
|
|
|
|
|
limiter = MagicMock()
|
|
|
|
|
|
limiter.get_standalone_rpm_key.return_value = "rpm:ukey:standalone-1:1"
|
|
|
|
|
|
limiter.get_key_rpm_key.return_value = "rpm:key:standalone-1:1"
|
|
|
|
|
|
limiter.check_and_consume = AsyncMock(return_value=RpmCheckResult(allowed=True))
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.get_user_rpm_limiter",
|
|
|
|
|
|
AsyncMock(return_value=limiter),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.SystemConfigService.get_config",
|
|
|
|
|
|
lambda *_a, **_k: 60,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await pipeline._check_user_rate_limit(request, db, user, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
limiter.check_and_consume.assert_awaited_once_with(
|
|
|
|
|
|
user_rpm_key="rpm:ukey:standalone-1:1",
|
|
|
|
|
|
user_rpm_limit=60,
|
|
|
|
|
|
key_rpm_key="rpm:key:standalone-1:1",
|
|
|
|
|
|
key_rpm_limit=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_check_user_rate_limit_returns_429_with_user_scope_header(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
|
request.state = MagicMock()
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
user = MagicMock(id="user-1", rate_limit=3)
|
|
|
|
|
|
api_key = MagicMock(id="key-1", is_standalone=False, rate_limit=10)
|
|
|
|
|
|
|
|
|
|
|
|
limiter = MagicMock()
|
|
|
|
|
|
limiter.get_user_rpm_key.return_value = "rpm:user:user-1:1"
|
|
|
|
|
|
limiter.get_key_rpm_key.return_value = "rpm:key:key-1:1"
|
|
|
|
|
|
limiter.get_retry_after.return_value = 23
|
|
|
|
|
|
limiter.check_and_consume = AsyncMock(
|
|
|
|
|
|
return_value=RpmCheckResult(
|
|
|
|
|
|
allowed=False,
|
|
|
|
|
|
scope="user",
|
|
|
|
|
|
limit=3,
|
|
|
|
|
|
remaining=0,
|
|
|
|
|
|
retry_after=23,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.get_user_rpm_limiter",
|
|
|
|
|
|
AsyncMock(return_value=limiter),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"src.api.base.pipeline.SystemConfigService.get_config",
|
|
|
|
|
|
lambda *_a, **_k: 60,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._check_user_rate_limit(request, db, user, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 429
|
|
|
|
|
|
assert exc_info.value.headers == {
|
|
|
|
|
|
"Retry-After": "23",
|
|
|
|
|
|
"X-RateLimit-Limit": "3",
|
|
|
|
|
|
"X-RateLimit-Remaining": "0",
|
|
|
|
|
|
"X-RateLimit-Scope": "user",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 22:57:23 +08:00
|
|
|
|
class TestPipelineTokenPrefixAuth:
|
|
|
|
|
|
"""Tests token-prefix auth isolation."""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_try_token_prefix_auth_uses_isolated_session(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {}
|
|
|
|
|
|
mock_request.client = MagicMock(host="127.0.0.1")
|
|
|
|
|
|
|
|
|
|
|
|
route_db = MagicMock()
|
|
|
|
|
|
auth_db = MagicMock()
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_token = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
async def authenticate(db: Any, token: str, client_ip: str) -> tuple[Any, Any]:
|
|
|
|
|
|
assert db is auth_db
|
|
|
|
|
|
assert token == "ae_test"
|
|
|
|
|
|
assert client_ip == "127.0.0.1"
|
|
|
|
|
|
return mock_user, mock_token
|
|
|
|
|
|
|
|
|
|
|
|
with patch("src.api.base.pipeline.create_session", return_value=auth_db):
|
|
|
|
|
|
with patch("src.utils.request_utils.get_client_ip", return_value="127.0.0.1"):
|
|
|
|
|
|
with patch("src.core.modules.hooks.get_hook_dispatcher") as mock_get_dispatcher:
|
|
|
|
|
|
dispatcher = MagicMock()
|
|
|
|
|
|
dispatcher.dispatch = AsyncMock(
|
|
|
|
|
|
return_value=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"prefix": "ae_",
|
|
|
|
|
|
"module": "management_tokens",
|
|
|
|
|
|
"authenticate": authenticate,
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
mock_get_dispatcher.return_value = dispatcher
|
|
|
|
|
|
|
|
|
|
|
|
result = await pipeline._try_token_prefix_auth(
|
|
|
|
|
|
"ae_test", mock_request, route_db
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result == (mock_user, mock_token)
|
|
|
|
|
|
dispatcher.dispatch.assert_awaited_once_with(AUTH_TOKEN_PREFIX_AUTHENTICATORS)
|
|
|
|
|
|
auth_db.expunge.assert_any_call(mock_user)
|
|
|
|
|
|
auth_db.expunge.assert_any_call(mock_token)
|
|
|
|
|
|
auth_db.close.assert_called_once()
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPipelineAdminAuth:
|
|
|
|
|
|
"""测试管理员认证"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_missing_token(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试缺少管理员令牌"""
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {}
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
|
assert "管理员凭证" in exc_info.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_invalid_token(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试无效的管理员令牌"""
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"authorization": "Bearer invalid-token"}
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
side_effect=HTTPException(status_code=401, detail="Invalid token"),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
|
await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_success(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试管理员认证成功"""
|
2026-01-19 03:19:17 +08:00
|
|
|
|
created_at = datetime.now(timezone.utc)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-123"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.is_deleted = False
|
2026-01-10 18:43:53 +08:00
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.created_at = created_at
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-123",
|
|
|
|
|
|
}
|
2025-12-18 02:20:53 +08:00
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "admin-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
2026-03-18 00:12:00 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
2026-03-17 16:34:09 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
2025-12-18 02:20:53 +08:00
|
|
|
|
):
|
2026-01-10 18:43:53 +08:00
|
|
|
|
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
2025-12-18 02:20:53 +08:00
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
2025-12-18 02:20:53 +08:00
|
|
|
|
assert mock_request.state.user_id == "admin-123"
|
2026-03-17 16:34:09 +08:00
|
|
|
|
assert mock_request.state.user_session_id == "session-123"
|
2026-03-18 00:12:00 +08:00
|
|
|
|
mock_db.commit.assert_called_once()
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试 bearer (小写) 前缀也能正确解析"""
|
2026-01-19 03:19:17 +08:00
|
|
|
|
created_at = datetime.now(timezone.utc)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-123"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
2025-12-18 19:07:20 +08:00
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.is_deleted = False
|
2026-01-10 18:43:53 +08:00
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.created_at = created_at
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-123",
|
|
|
|
|
|
}
|
2025-12-18 19:07:20 +08:00
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "admin-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
) as mock_verify,
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
2026-03-18 00:12:00 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
2026-03-17 16:34:09 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
2026-01-10 18:43:53 +08:00
|
|
|
|
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
mock_verify.assert_awaited_once_with("valid-token", token_type="access")
|
2026-01-10 18:43:53 +08:00
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_rejects_legacy_token_without_session_id(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-legacy",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={"user_id": "admin-123", "created_at": created_at.isoformat()},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"token_identity_matches_user",
|
|
|
|
|
|
return_value=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException, match="登录会话已失效,请重新登录"):
|
|
|
|
|
|
await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
2026-03-18 00:12:00 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_rollback_on_session_touch_commit_failure(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-123",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
mock_db.commit.side_effect = RuntimeError("lock timeout")
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "admin-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
|
|
|
|
|
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
|
|
|
|
|
mock_db.rollback.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_skips_commit_when_session_touch_not_needed(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-123",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "admin-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.touch_session",
|
|
|
|
|
|
return_value=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
|
|
|
|
|
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
|
|
|
|
|
mock_db.commit.assert_not_called()
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_admin_uses_identity_helper_for_session_touch(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-123"
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "admin-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.role = UserRole.ADMIN
|
|
|
|
|
|
mock_user.email = "admin@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-admin-123",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "admin-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.get_request_identity_metadata",
|
|
|
|
|
|
return_value=MagicMock(client_ip="203.0.113.10", user_agent="admin-agent/1.0"),
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.touch_session",
|
|
|
|
|
|
return_value=True,
|
|
|
|
|
|
) as mock_touch,
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
|
|
|
|
|
await pipeline._authenticate_admin(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
mock_touch.assert_called_once_with(
|
|
|
|
|
|
mock_session,
|
|
|
|
|
|
client_ip="203.0.113.10",
|
|
|
|
|
|
user_agent="admin-agent/1.0",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
class TestPipelineUserAuth:
|
|
|
|
|
|
"""测试普通用户 JWT 认证"""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def pipeline(self) -> ApiRequestPipeline:
|
|
|
|
|
|
return ApiRequestPipeline()
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_user_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
"""测试 bearer (小写) 前缀也能正确解析"""
|
2026-01-19 03:19:17 +08:00
|
|
|
|
created_at = datetime.now(timezone.utc)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-456"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
2025-12-18 19:07:20 +08:00
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_user.is_active = True
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.is_deleted = False
|
2026-01-10 18:43:53 +08:00
|
|
|
|
mock_user.email = "user@example.com"
|
2026-01-19 03:19:17 +08:00
|
|
|
|
mock_user.created_at = created_at
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
2026-03-17 16:34:09 +08:00
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-user-456",
|
|
|
|
|
|
}
|
2025-12-18 19:07:20 +08:00
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "user-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-456",
|
|
|
|
|
|
},
|
|
|
|
|
|
) as mock_verify,
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
2026-03-18 00:12:00 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
2026-03-17 16:34:09 +08:00
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
2026-01-10 18:43:53 +08:00
|
|
|
|
user, management_token = await pipeline._authenticate_user(mock_request, mock_db)
|
2025-12-18 19:07:20 +08:00
|
|
|
|
|
|
|
|
|
|
mock_verify.assert_awaited_once_with("valid-token", token_type="access")
|
2026-01-10 18:43:53 +08:00
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
2026-03-17 16:34:09 +08:00
|
|
|
|
assert mock_request.state.user_session_id == "session-456"
|
2026-03-18 00:12:00 +08:00
|
|
|
|
mock_db.commit.assert_called_once()
|
2026-03-17 16:34:09 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_user_rejects_legacy_token_without_session_id(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""历史 JWT 无 session_id 时应拒绝。"""
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-user-legacy",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={"user_id": "user-123", "created_at": created_at.isoformat()},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"token_identity_matches_user",
|
|
|
|
|
|
return_value=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException, match="登录会话已失效,请重新登录"):
|
|
|
|
|
|
await pipeline._authenticate_user(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_user_requires_device_id(self, pipeline: ApiRequestPipeline) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {"authorization": "Bearer valid-token"}
|
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-456"
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "user-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-456",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"token_identity_matches_user",
|
|
|
|
|
|
return_value=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
|
|
|
|
|
with pytest.raises(HTTPException, match="缺少或无效的设备标识"):
|
|
|
|
|
|
await pipeline._authenticate_user(mock_request, mock_db)
|
2026-03-18 00:12:00 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_user_skips_commit_when_session_touch_not_needed(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-456"
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.email = "user@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-user-456",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "user-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-456",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.touch_session",
|
|
|
|
|
|
return_value=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
|
|
|
|
|
user, management_token = await pipeline._authenticate_user(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
assert user == mock_user
|
|
|
|
|
|
assert management_token is None
|
|
|
|
|
|
mock_db.commit.assert_not_called()
|
2026-03-31 19:19:04 +08:00
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_authenticate_user_uses_identity_helper_for_session_touch(
|
|
|
|
|
|
self, pipeline: ApiRequestPipeline
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
created_at = datetime.now(timezone.utc)
|
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
|
mock_session.id = "session-456"
|
|
|
|
|
|
|
|
|
|
|
|
mock_user = MagicMock()
|
|
|
|
|
|
mock_user.id = "user-123"
|
|
|
|
|
|
mock_user.is_active = True
|
|
|
|
|
|
mock_user.is_deleted = False
|
|
|
|
|
|
mock_user.email = "user@example.com"
|
|
|
|
|
|
mock_user.created_at = created_at
|
|
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
|
mock_request.headers = {
|
|
|
|
|
|
"authorization": "Bearer valid-token",
|
|
|
|
|
|
"X-Client-Device-Id": "device-user-456",
|
|
|
|
|
|
}
|
|
|
|
|
|
mock_request.state = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
|
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(
|
|
|
|
|
|
pipeline.auth_service,
|
|
|
|
|
|
"verify_token",
|
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
|
return_value={
|
|
|
|
|
|
"user_id": "user-123",
|
|
|
|
|
|
"created_at": created_at.isoformat(),
|
|
|
|
|
|
"session_id": "session-456",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.get_request_identity_metadata",
|
|
|
|
|
|
return_value=MagicMock(client_ip="198.51.100.25", user_agent="user-agent/2.0"),
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.get_active_session",
|
|
|
|
|
|
return_value=mock_session,
|
|
|
|
|
|
),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"src.api.base.pipeline.SessionService.touch_session",
|
|
|
|
|
|
return_value=True,
|
|
|
|
|
|
) as mock_touch,
|
|
|
|
|
|
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
|
|
|
|
|
):
|
|
|
|
|
|
await pipeline._authenticate_user(mock_request, mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
mock_touch.assert_called_once_with(
|
|
|
|
|
|
mock_session,
|
|
|
|
|
|
client_ip="198.51.100.25",
|
|
|
|
|
|
user_agent="user-agent/2.0",
|
|
|
|
|
|
)
|