ForcePilot/backend/test/integration/api/channels/test_plugin_router.py
Kris df116a069c test: 补全全量接口参数校验与权限控制测试用例
新增并完善多模块接口测试用例,覆盖:
1. 权限校验:认证缺失、普通用户越权访问场景
2. 参数合法性校验:空值、格式错误、边界值、枚举约束
3. 响应字段增强:补充业务字段断言与分页回传校验
4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
2026-07-11 21:41:45 +08:00

449 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Integration tests for channels plugin_router endpoints.
覆盖插件管理域PLG-QUERY-01/02 + PLG-LIFE-01~07 + PLG-INSTALL +
PLG-UNINSTALL-FILE + PLG-CONFIG + PLG-BATCH-LIFE的鉴权矩阵与
非存在插件 404 错误路径。install / uninstall 需超级管理员权限,
其余端点要求管理员。生命周期操作以 ``NON_EXISTENT_PLUGIN_ID`` 触发
``PLUGIN_NOT_FOUND`` → 404。
"""
from __future__ import annotations
import httpx
import pytest
from .conftest import BASE_URL, NON_EXISTENT_PLUGIN_ID
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
PLUGINS_URL = f"{BASE_URL}/plugins"
# =============================================================================
# === Auth three-tier for GET /plugins ===
# =============================================================================
async def test_list_plugins_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(PLUGINS_URL)
# Assert
assert response.status_code == 401
async def test_list_plugins_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(PLUGINS_URL, headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_admin_can_list_plugins(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(PLUGINS_URL, headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert "plugins" in payload["data"]
# =============================================================================
# === GET /plugins/catalog — auth check ===
# =============================================================================
async def test_list_plugin_catalog_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{PLUGINS_URL}/catalog")
# Assert
assert response.status_code == 401
async def test_list_plugin_catalog_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{PLUGINS_URL}/catalog", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_admin_can_list_plugin_catalog(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{PLUGINS_URL}/catalog", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert "plugins" in payload["data"]
async def test_catalog_static_path_not_captured_as_plugin_id(test_client: httpx.AsyncClient, admin_headers):
# Act — GET /plugins/catalog 应路由到 list_plugin_catalog返回 200
# 而非被 /{plugin_id} 捕获为 plugin_id="catalog"(返回 404
response = await test_client.get(f"{PLUGINS_URL}/catalog", headers=admin_headers)
# Assert — 200 表示命中 catalog 端点(而非被 /{plugin_id} 捕获返回 404
assert response.status_code == 200, response.text
# =============================================================================
# === POST /plugins/install — superadmin only ===
# =============================================================================
async def test_install_plugin_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"source_type": "path", "source": "/tmp/nonexistent_plugin"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body)
# Assert
assert response.status_code == 401
async def test_install_plugin_rejects_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"source_type": "path", "source": "/tmp/nonexistent_plugin"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_superadmin_install_nonexistent_source_fails(test_client: httpx.AsyncClient, admin_headers):
# Arrange — admin_headers 为 superadmin通过 get_superadmin_user 守门后
# 抵达 dispatch/tmp/nonexistent_plugin 路径不存在install 在 adapter 层
# 失败,映射为 PLUGIN_FAILED(500) 或 VALIDATION_ERROR(400)。
# standard_user 被拒场景由 test_install_plugin_rejects_standard_user 覆盖。
body = {"source_type": "path", "source": "/tmp/nonexistent_plugin"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=admin_headers)
# Assert — 非 403已通过守门具体失败码取决于 adapter 实现
assert response.status_code in (400, 500), response.text
async def test_install_plugin_rejects_invalid_source_type(test_client: httpx.AsyncClient, admin_headers):
# Arrange — source_type 为 Literal["path","url","registry"],非法值触发 422
body = {"source_type": "ftp", "source": "/tmp/nonexistent_plugin"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_install_plugin_rejects_missing_source(test_client: httpx.AsyncClient, admin_headers):
# Arrange — source 必填字段缺失
body = {"source_type": "path"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_install_plugin_rejects_missing_source_type(test_client: httpx.AsyncClient, admin_headers):
# Arrange — source_type 必填字段缺失
body = {"source": "/tmp/nonexistent_plugin"}
# Act
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === DELETE /plugins/{plugin_id} — superadmin only ===
# =============================================================================
async def test_uninstall_plugin_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.delete(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}")
# Assert
assert response.status_code == 401
async def test_uninstall_plugin_rejects_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.delete(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_superadmin_uninstall_nonexistent_plugin_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act — admin_headers 为 superadmin通过 get_superadmin_user 守门后
# dispatch 查找 NON_EXISTENT_PLUGIN_ID 失败 → PLUGIN_NOT_FOUND → 404。
response = await test_client.delete(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === GET /plugins/{plugin_id} — non-existent (404) ===
# =============================================================================
async def test_get_plugin_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}")
# Assert
assert response.status_code == 401
async def test_get_plugin_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_get_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === GET/PUT /plugins/{plugin_id}/config — non-existent (404) ===
# =============================================================================
async def test_get_plugin_config_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config")
# Assert
assert response.status_code == 401
async def test_get_plugin_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(
f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", headers=standard_user["headers"]
)
# Assert
assert response.status_code == 403
async def test_get_plugin_config_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_update_plugin_config_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"config": {"key": "value"}, "apply_mode": "hot"}
# Act
response = await test_client.put(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body)
# Assert
assert response.status_code == 401
async def test_update_plugin_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"config": {"key": "value"}, "apply_mode": "hot"}
# Act
response = await test_client.put(
f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body, headers=standard_user["headers"]
)
# Assert
assert response.status_code == 403
async def test_update_plugin_config_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Arrange — 合法请求体,确保非 422 而走到 use case 触发 404
body = {"config": {"key": "value"}, "apply_mode": "hot"}
# Act
response = await test_client.put(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body, headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_update_plugin_config_rejects_missing_config(test_client: httpx.AsyncClient, admin_headers):
# Arrange — config 必填字段缺失
body = {"apply_mode": "hot"}
# Act
response = await test_client.put(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_update_plugin_config_rejects_missing_apply_mode(test_client: httpx.AsyncClient, admin_headers):
# Arrange — apply_mode 必填字段缺失
body = {"config": {"key": "value"}}
# Act
response = await test_client.put(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_update_plugin_config_rejects_invalid_apply_mode(test_client: httpx.AsyncClient, admin_headers):
# Arrange — apply_mode 为 Literal["hot","restart_required"],非法值触发 422
body = {"config": {"key": "value"}, "apply_mode": "invalid_mode"}
# Act
response = await test_client.put(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/config", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === Lifecycle endpoints — non-existent plugin (404) ===
# =============================================================================
# 生命周期端点load/start/pause/resume/stop/unload/reload共用 get_admin_user
# 依赖,鉴权矩阵一致。以 load 为代表验证 401/403其余端点复用同一鉴权策略。
async def test_load_plugin_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/load")
# Assert
assert response.status_code == 401
async def test_load_plugin_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(
f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/load", headers=standard_user["headers"]
)
# Assert
assert response.status_code == 403
async def test_load_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/load", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_start_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/start", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_pause_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/pause", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_resume_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/resume", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_stop_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/stop", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_unload_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/unload", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
async def test_reload_plugin_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(f"{PLUGINS_URL}/{NON_EXISTENT_PLUGIN_ID}/reload", headers=admin_headers)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === POST /plugins/batch/start — empty plugin_ids ===
# =============================================================================
async def test_batch_start_plugins_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"plugin_ids": ["some_plugin"]}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/start", json=body)
# Assert
assert response.status_code == 401
async def test_batch_start_plugins_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"plugin_ids": ["some_plugin"]}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/start", json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_batch_start_plugins_with_empty_plugin_ids(test_client: httpx.AsyncClient, admin_headers):
# Arrange — 空 plugin_ids 且无 filterBatchPluginLifecycleCmd.__post_init__ 抛 ValidationError → 400
body = {"plugin_ids": []}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/start", json=body, headers=admin_headers)
# Assert
assert response.status_code == 400, response.text
async def test_batch_start_plugins_with_empty_body(test_client: httpx.AsyncClient, admin_headers):
# Act — 空请求体字段均取默认值plugin_ids=[] + filter=None__post_init__ 抛 ValidationError → 400
response = await test_client.post(f"{PLUGINS_URL}/batch/start", json={}, headers=admin_headers)
# Assert
assert response.status_code == 400, response.text
# =============================================================================
# === POST /plugins/batch/stop — auth check ===
# =============================================================================
async def test_batch_stop_plugins_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"plugin_ids": ["some_plugin"]}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/stop", json=body)
# Assert
assert response.status_code == 401
async def test_batch_stop_plugins_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"plugin_ids": ["some_plugin"]}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/stop", json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_batch_stop_plugins_with_empty_plugin_ids(test_client: httpx.AsyncClient, admin_headers):
# Arrange — 空 plugin_ids 且无 filter__post_init__ 抛 ValidationError → 400
body = {"plugin_ids": []}
# Act
response = await test_client.post(f"{PLUGINS_URL}/batch/stop", json=body, headers=admin_headers)
# Assert
assert response.status_code == 400, response.text
# =============================================================================
# === Static path /install not captured as plugin_id ===
# =============================================================================
async def test_install_static_path_not_captured_as_plugin_id(test_client: httpx.AsyncClient, admin_headers):
# Arrange
body = {"source_type": "path", "source": "/tmp/nonexistent_plugin"}
# Act — POST /plugins/install 应路由到 install_pluginadmin=superadmin 通过
# get_superadmin_user 守门),而非被 /{plugin_id} 捕获为 plugin_id="install"。
# 若被 /{plugin_id} 捕获则返回 404PLUGIN_NOT_FOUND命中 install 则返回
# 400/500adapter 安装失败)。据此区分路由是否正确。
response = await test_client.post(f"{PLUGINS_URL}/install", json=body, headers=admin_headers)
# Assert — 非 404 表示命中 install_plugin而非被 /{plugin_id} 捕获)
assert response.status_code in (400, 500), response.text