mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进
- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用 - 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整 - aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s - OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突 - ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环 - 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化 - 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义 - 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化 - 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用 - 补充相关测试用例
This commit is contained in:
@@ -9,8 +9,10 @@ from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.api_keys.routes import (
|
||||
AdminCreateStandaloneKeyAdapter,
|
||||
AdminGetFullKeyAdapter,
|
||||
AdminToggleApiKeyAdapter,
|
||||
AdminUpdateApiKeyAdapter,
|
||||
)
|
||||
from src.api.admin.api_keys.routes import router as admin_api_keys_router
|
||||
from src.api.admin.users.routes import (
|
||||
@@ -20,6 +22,7 @@ from src.api.admin.users.routes import (
|
||||
from src.api.admin.users.routes import router as admin_users_router
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.database import get_db
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
|
||||
|
||||
def _build_context(db: MagicMock) -> SimpleNamespace:
|
||||
@@ -296,3 +299,114 @@ def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.M
|
||||
payload = response.json()
|
||||
assert payload["id"] == "sa-key-2"
|
||||
assert "is_locked" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_standalone_key_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
captured: dict[str, object] = {}
|
||||
created_key = SimpleNamespace(
|
||||
id="sa-key-3",
|
||||
name="Standalone Key 3",
|
||||
get_display_key=lambda: "sk-stand...9012",
|
||||
is_active=True,
|
||||
rate_limit=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
)
|
||||
|
||||
def _create_api_key(**kwargs: object) -> tuple[SimpleNamespace, str]:
|
||||
captured.update(kwargs)
|
||||
return created_key, "sk-created"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.ApiKeyService.create_api_key", _create_api_key
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.initialize_api_key_wallet",
|
||||
lambda *_a, **_k: SimpleNamespace(id="wallet-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"id": "wallet-1"},
|
||||
)
|
||||
|
||||
adapter = AdminCreateStandaloneKeyAdapter(
|
||||
CreateApiKeyRequest(
|
||||
name="Standalone Key 3",
|
||||
initial_balance_usd=10,
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
)
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["id"] == "sa-key-3"
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_standalone_key_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
existing_key = SimpleNamespace(id="sa-key-4", is_standalone=True)
|
||||
_mock_query_first(db, existing_key)
|
||||
|
||||
updated_key = SimpleNamespace(
|
||||
id="sa-key-4",
|
||||
name="Standalone Key 4",
|
||||
get_display_key=lambda: "sk-stand...3456",
|
||||
is_active=True,
|
||||
rate_limit=None,
|
||||
expires_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _update_api_key(_db: MagicMock, _key_id: str, **kwargs: object) -> SimpleNamespace:
|
||||
captured.update(kwargs)
|
||||
return updated_key
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.ApiKeyService.update_api_key", _update_api_key
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes._ensure_standalone_wallet",
|
||||
lambda *_a, **_k: SimpleNamespace(id="wallet-2"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.serialize_wallet_summary",
|
||||
lambda _wallet: {"id": "wallet-2"},
|
||||
)
|
||||
|
||||
adapter = AdminUpdateApiKeyAdapter(
|
||||
key_id="sa-key-4",
|
||||
key_data=CreateApiKeyRequest(
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
),
|
||||
)
|
||||
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result["id"] == "sa-key-4"
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
|
||||
128
tests/api/test_admin_provider_routes.py
Normal file
128
tests/api/test_admin_provider_routes.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.admin.providers.routes import (
|
||||
AdminDeleteProviderAdapter,
|
||||
AdminProviderDeleteTaskStatusAdapter,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_adapter_submits_async_task_and_deactivates_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(id="provider-1", name="Provider 1", is_active=True)
|
||||
db.query.return_value.filter.return_value.first.return_value = provider
|
||||
|
||||
submit_task_mock = AsyncMock(return_value="task-1")
|
||||
invalidate_models_mock = AsyncMock()
|
||||
invalidate_resolve_mock = AsyncMock()
|
||||
invalidate_provider_cache_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.submit_provider_delete",
|
||||
submit_task_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.invalidate_models_list_cache",
|
||||
invalidate_models_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.ModelCacheService.invalidate_all_resolve_cache",
|
||||
invalidate_resolve_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.ProviderCacheService.invalidate_provider_cache",
|
||||
invalidate_provider_cache_mock,
|
||||
)
|
||||
|
||||
audit_calls: list[dict[str, object]] = []
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **kwargs: audit_calls.append(kwargs),
|
||||
)
|
||||
|
||||
adapter = AdminDeleteProviderAdapter(provider_id="provider-1")
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result == {
|
||||
"task_id": "task-1",
|
||||
"status": "pending",
|
||||
"message": "删除任务已提交,提供商已进入后台删除队列",
|
||||
}
|
||||
submit_task_mock.assert_awaited_once_with("provider-1")
|
||||
assert provider.is_active is False
|
||||
db.commit.assert_called_once()
|
||||
invalidate_models_mock.assert_awaited_once()
|
||||
invalidate_resolve_mock.assert_awaited_once()
|
||||
invalidate_provider_cache_mock.assert_awaited_once_with("provider-1")
|
||||
assert audit_calls[0]["action"] == "delete_provider"
|
||||
assert audit_calls[1]["task_id"] == "task-1"
|
||||
assert audit_calls[1]["provider_deactivated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_adapter_reuses_task_without_extra_commit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(id="provider-1", name="Provider 1", is_active=False)
|
||||
db.query.return_value.filter.return_value.first.return_value = provider
|
||||
|
||||
submit_task_mock = AsyncMock(return_value="task-1")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.submit_provider_delete",
|
||||
submit_task_mock,
|
||||
)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
add_audit_metadata=lambda **kwargs: None,
|
||||
)
|
||||
|
||||
adapter = AdminDeleteProviderAdapter(provider_id="provider-1")
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["task_id"] == "task-1"
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_task_status_adapter_returns_task_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
task = SimpleNamespace(
|
||||
task_id="task-1",
|
||||
provider_id="provider-1",
|
||||
status="running",
|
||||
stage="deleting_keys",
|
||||
total_keys=100,
|
||||
deleted_keys=25,
|
||||
total_endpoints=8,
|
||||
deleted_endpoints=2,
|
||||
message="deleted key batch 1/2",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.providers.routes.get_provider_delete_task",
|
||||
AsyncMock(return_value=task),
|
||||
)
|
||||
|
||||
context = SimpleNamespace(db=MagicMock(), request=SimpleNamespace(state=SimpleNamespace()))
|
||||
adapter = AdminProviderDeleteTaskStatusAdapter(provider_id="provider-1", task_id="task-1")
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result.task_id == "task-1"
|
||||
assert result.status == "running"
|
||||
assert result.stage == "deleting_keys"
|
||||
assert result.deleted_keys == 25
|
||||
assert result.deleted_endpoints == 2
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.users.routes import AdminCreateUserAdapter
|
||||
from src.api.admin.users.routes import router as admin_users_router
|
||||
from src.database import get_db
|
||||
|
||||
@@ -91,3 +92,53 @@ def test_list_users_uses_wallet_batch_lookup(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert response.json()[1]["unlimited"] is False
|
||||
batch_getter.assert_called_once()
|
||||
assert batch_getter.call_args.args[1] == ["user-1", "user-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_adapter_preserves_empty_restriction_lists(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _create_user(**kwargs: Any) -> SimpleNamespace:
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
id="user-3",
|
||||
email="u3@example.com",
|
||||
username="user3",
|
||||
role=SimpleNamespace(value="user"),
|
||||
is_active=True,
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes.UserService.create_user", _create_user)
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.users.routes._serialize_user",
|
||||
lambda _db, user: {"id": user.id},
|
||||
)
|
||||
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
request=SimpleNamespace(state=SimpleNamespace()),
|
||||
ensure_json_body=lambda: {
|
||||
"username": "user3",
|
||||
"password": "Abcd12",
|
||||
"email": "u3@example.com",
|
||||
"role": "user",
|
||||
"initial_gift_usd": 10,
|
||||
"allowed_providers": [],
|
||||
"allowed_api_formats": [],
|
||||
"allowed_models": [],
|
||||
},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
result = await AdminCreateUserAdapter().handle(context)
|
||||
|
||||
assert result == {"id": "user-3"}
|
||||
assert captured["allowed_providers"] == []
|
||||
assert captured["allowed_api_formats"] == []
|
||||
assert captured["allowed_models"] == []
|
||||
|
||||
Reference in New Issue
Block a user