mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 13:25:45 +00:00
feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from module_plugin.controller.plugin_controller import diagnose_system_plugin
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnose_api_attaches_runtime_without_changing_static_ok(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
app = FastAPI()
|
||||
reporter = object()
|
||||
app.state.plugin_diagnostics_reporter = reporter
|
||||
app.state.redis = object()
|
||||
app.state.plugin_application_runtime = SimpleNamespace(ready_key='isolated:ready')
|
||||
operation = SimpleNamespace(
|
||||
diagnose_plugin_with_audit_services=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'pluginId': 'demo',
|
||||
'message': '插件诊断包生成完成',
|
||||
'audit': {'available': True},
|
||||
}
|
||||
)
|
||||
)
|
||||
read = AsyncMock(return_value={'state': 'unavailable', 'observedTotals': None})
|
||||
monkeypatch.setattr('module_plugin.controller.plugin_controller.get_plugin_operation_service', lambda: operation)
|
||||
monkeypatch.setattr('plugins.core.runtime.diagnostics_query.read_plugin_runtime_diagnostics', read)
|
||||
response = await diagnose_system_plugin(Request({'type': 'http', 'app': app}), object(), 'demo')
|
||||
payload = json.loads(response.body)['data']
|
||||
assert payload['ok'] is True
|
||||
assert payload['runtime'] == {'state': 'unavailable', 'observedTotals': None}
|
||||
read.assert_awaited_once_with('demo', reporter=reporter, redis=app.state.redis, ready_key='isolated:ready')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('complete', [True, False])
|
||||
async def test_failed_check_remains_visible_but_generation_failure_keeps_error_envelope(
|
||||
monkeypatch: pytest.MonkeyPatch, complete: bool
|
||||
) -> None:
|
||||
payload = {'ok': False, 'message': '插件诊断包生成完成,发现问题', 'info': {'pluginId': 'demo'}}
|
||||
if complete:
|
||||
payload.update(check={'ok': False}, config={}, menuPlan={}, audit={})
|
||||
operation = SimpleNamespace(diagnose_plugin_with_audit_services=AsyncMock(return_value=payload))
|
||||
monkeypatch.setattr('module_plugin.controller.plugin_controller.get_plugin_operation_service', lambda: operation)
|
||||
monkeypatch.setattr(
|
||||
'plugins.core.runtime.diagnostics_query.read_plugin_runtime_diagnostics',
|
||||
AsyncMock(return_value={'state': 'observed'}),
|
||||
)
|
||||
response = await diagnose_system_plugin(Request({'type': 'http', 'app': FastAPI()}), object(), 'demo')
|
||||
result = json.loads(response.body)
|
||||
assert result['code'] == (200 if complete else 601)
|
||||
assert result['data']['ok'] is False
|
||||
assert result['data']['runtime'] == {'state': 'observed'}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
from module_plugin.controller.plugin_metrics_controller import (
|
||||
get_plugin_config_status,
|
||||
get_plugin_runtime_metrics,
|
||||
plugin_metrics_controller,
|
||||
)
|
||||
from plugins.core.runtime.metrics import PluginRuntimeMetrics
|
||||
from plugins.core.runtime.metrics_store import PluginMetricsReporter
|
||||
|
||||
|
||||
def test_runtime_metrics_endpoint_is_readonly_and_permission_gated() -> None:
|
||||
"""运行指标仅通过受插件查询权限保护的 GET 接口公开。"""
|
||||
route = plugin_metrics_controller.routes[0]
|
||||
assert route.path == '/system/plugin/runtime/metrics'
|
||||
assert route.methods == {'GET'}
|
||||
assert any(
|
||||
isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
||||
and dependency.dependency.perm == 'system:plugin:query'
|
||||
for dependency in route.dependencies
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_endpoint_without_explicit_runtime_reports_unsupported() -> None:
|
||||
"""未加载显式插件时不可用状态不能伪装成全部指标为零。"""
|
||||
response = await get_plugin_runtime_metrics(Request({'type': 'http', 'app': FastAPI()}))
|
||||
payload = json.loads(response.body)['data']
|
||||
assert payload['supported'] is False
|
||||
assert payload['scope'] == 'unavailable'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_endpoint_forwards_plugin_filter() -> None:
|
||||
"""接口保留报告器的采样范围并传递插件过滤条件。"""
|
||||
app = FastAPI()
|
||||
reporter = PluginMetricsReporter(PluginRuntimeMetrics())
|
||||
reporter.read = AsyncMock(return_value={'ok': True, 'scope': 'current_worker', 'series': []})
|
||||
app.state.plugin_metrics_reporter = reporter
|
||||
response = await get_plugin_runtime_metrics(Request({'type': 'http', 'app': app}), 'metric_demo')
|
||||
assert json.loads(response.body)['data']['scope'] == 'current_worker'
|
||||
reporter.read.assert_awaited_once_with('metric_demo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_status_is_query_gated_and_never_exposes_config_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""读取敏感配置只用于计算版本,API 与无指标场景均不回显明文。"""
|
||||
route = next(route for route in plugin_metrics_controller.routes if route.path.endswith('/config/status'))
|
||||
assert route.methods == {'GET'}
|
||||
assert any(
|
||||
isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
||||
and dependency.dependency.perm == 'system:plugin:query'
|
||||
for dependency in route.dependencies
|
||||
)
|
||||
runtime = SimpleNamespace(
|
||||
get_plugin_config=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'configs': [{'key': 'api_key', 'value': 'private-config-secret', 'secret': True}],
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'module_plugin.controller.plugin_metrics_controller.get_plugin_runtime_service',
|
||||
lambda: runtime,
|
||||
)
|
||||
response = await get_plugin_config_status(Request({'type': 'http', 'app': FastAPI()}), 'config_demo')
|
||||
data = json.loads(response.body)['data']
|
||||
assert data['state'] == 'unobserved' and data['scope'] == 'unavailable'
|
||||
assert 'private-config-secret' not in response.body.decode() and 'api_key' not in response.body.decode()
|
||||
runtime.get_plugin_config.assert_awaited_once_with('config_demo', reveal_secret=True)
|
||||
runtime.get_plugin_config.return_value = {'ok': False, 'message': 'private exception value'}
|
||||
failure = await get_plugin_config_status(Request({'type': 'http', 'app': FastAPI()}), 'config_demo')
|
||||
assert 'private exception value' not in failure.body.decode()
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
from module_plugin.controller import plugin_release_controller as controller
|
||||
|
||||
DIGEST = 'a' * 64
|
||||
|
||||
|
||||
def test_artifact_http_endpoints_are_get_only_and_require_query_permission() -> None:
|
||||
"""验证制品 HTTP 接口只接受查询且要求插件查询权限。"""
|
||||
routes = controller.plugin_release_controller.routes
|
||||
assert {route.path for route in routes} == {
|
||||
'/system/plugin/artifacts/list',
|
||||
'/system/plugin/release/status',
|
||||
'/system/plugin/release/plan',
|
||||
}
|
||||
for route in routes:
|
||||
assert route.methods == {'GET'}
|
||||
assert any(
|
||||
isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
||||
and dependency.dependency.perm == 'system:plugin:query'
|
||||
for dependency in route.dependencies
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_endpoints_forward_filters_and_plan_target(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证只读接口正确传递过滤条件与目标制品信息。"""
|
||||
service = SimpleNamespace(
|
||||
catalog=SimpleNamespace(list_artifacts=AsyncMock(return_value={'ok': True, 'artifacts': []})),
|
||||
status=AsyncMock(return_value={'ok': True, 'releases': []}),
|
||||
plan=AsyncMock(return_value={'ok': True, 'pluginId': 'demo', 'digest': DIGEST}),
|
||||
)
|
||||
monkeypatch.setattr(controller, 'get_plugin_deployment_service', lambda: service)
|
||||
responses = [
|
||||
await controller.list_plugin_artifacts('demo'),
|
||||
await controller.get_plugin_release_status('demo'),
|
||||
await controller.plan_plugin_release('demo', DIGEST),
|
||||
]
|
||||
assert all(json.loads(response.body)['success'] for response in responses)
|
||||
service.catalog.list_artifacts.assert_awaited_once_with('demo')
|
||||
service.status.assert_awaited_once_with('demo')
|
||||
service.plan.assert_awaited_once_with('demo', DIGEST)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_feature_returns_clear_blocked_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证功能关闭时返回明确的受限响应。"""
|
||||
|
||||
def unavailable() -> None:
|
||||
raise ValueError('签名制品发布功能未启用')
|
||||
|
||||
monkeypatch.setattr(controller, 'get_plugin_deployment_service', unavailable)
|
||||
body = json.loads((await controller.get_plugin_release_status()).body)
|
||||
assert body['success'] is False
|
||||
assert body['data']['status'] == 'blocked'
|
||||
assert '未启用' in body['msg']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_database_error_does_not_expose_connection_details(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证意外数据库异常不会将连接细节返回客户端。"""
|
||||
|
||||
def unavailable() -> None:
|
||||
raise RuntimeError('mysql://secret-user:secret-password@host')
|
||||
|
||||
monkeypatch.setattr(controller, 'get_plugin_deployment_service', unavailable)
|
||||
response = await controller.list_plugin_artifacts()
|
||||
assert json.loads(response.body)['success'] is False
|
||||
assert b'secret-password' not in response.body
|
||||
Reference in New Issue
Block a user