Files
sub2api/backend/internal/handler/admin/module_handler_test.go
shaw 8ba2fbd786 feat(plugin): Phase-1/1.5 进程内插件内核 + 开发套件后端
Caddy 式进程内模块系统(详见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md):
- internal/plugin:命名空间注册表(init 注册/重复 panic)、Provision→Validate→Start→Stop
  生命周期(Start 半途逆序回滚、Stop 逆序+ctx deadline)、Host 四项 ports 能力面、
  modules: 配置子树(enabled 三态;viper 含点 key 手工提取)、Runtime 状态机
- internal/plugin/plugintest:模块测试夹具(NewHost Options + RunLifecycle)
- internal/modules:standard/imports.go 唯一插装清单 + job.hello 示例模块(默认 disabled)
- Wire/main 接入:Build+Start 失败 fail-fast(显式 Cleanup 防 leader 锁泄漏)、
  runtime.Stop 在 cleanup 并行组首位(先于 Redis/Ent)
- admin 可观测:GET /api/v1/admin/modules(只读,错误文本脱敏)
- 默认配置零行为变更(Phase-0 安全网 + bench allocs 零漂移验证)

含两轮对抗式审计修复(1×P1 启动失败泄漏 leader 锁 + 11×P2)与回归测试。
2026-06-11 22:58:51 +08:00

178 lines
5.7 KiB
Go
Raw Permalink 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.
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type moduleStatusSourceStub struct {
statuses []plugin.ModuleStatus
}
func (s *moduleStatusSourceStub) Snapshot() []plugin.ModuleStatus {
return s.statuses
}
type moduleListEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Modules []struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Error string `json:"error"`
} `json:"modules"`
} `json:"data"`
}
func performModuleList(t *testing.T, source moduleStatusSource) (*httptest.ResponseRecorder, moduleListEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := &ModuleHandler{runtime: source}
router.GET("/admin/modules", handler.List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
return w, envelope
}
func TestModuleHandlerListSerializesSnapshot(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "gateway.platform.anthropic", Enabled: true, State: plugin.StateRunning},
{ID: "job.hello", Enabled: false, State: plugin.StateRegistered},
}}
w, envelope := performModuleList(t, stub)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 0, envelope.Code)
require.Equal(t, "success", envelope.Message)
require.Len(t, envelope.Data.Modules, 2)
first := envelope.Data.Modules[0]
require.Equal(t, "gateway.platform.anthropic", first.ID)
require.Equal(t, "gateway.platform", first.Namespace)
require.Equal(t, "anthropic", first.Name)
require.True(t, first.Enabled)
require.Equal(t, "running", first.State)
require.Empty(t, first.Error)
// 未启用模块也在列:state=registered、enabled=false
second := envelope.Data.Modules[1]
require.Equal(t, "job.hello", second.ID)
require.Equal(t, "job", second.Namespace)
require.Equal(t, "hello", second.Name)
require.False(t, second.Enabled)
require.Equal(t, "registered", second.State)
require.Empty(t, second.Error)
// snake_case 字段恒定输出(含空 error)
require.Contains(t, w.Body.String(), `"error":""`)
require.Contains(t, w.Body.String(), `"state":"registered"`)
}
func TestModuleHandlerListSortsByID(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.zeta", Enabled: true, State: plugin.StateRunning},
{ID: "gateway.alpha", Enabled: true, State: plugin.StateRunning},
{ID: "job.alpha", Enabled: false, State: plugin.StateRegistered},
}}
_, envelope := performModuleList(t, stub)
ids := make([]string, 0, len(envelope.Data.Modules))
for _, m := range envelope.Data.Modules {
ids = append(ids, m.ID)
}
require.Equal(t, []string{"gateway.alpha", "job.alpha", "job.zeta"}, ids)
}
func TestModuleHandlerListRedactsErrorText(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.bad", Enabled: true, State: plugin.StateErrored, Err: "provision failed: access_token=ya29.secret-value"},
}}
_, envelope := performModuleList(t, stub)
require.Len(t, envelope.Data.Modules, 1)
errText := envelope.Data.Modules[0].Error
require.Contains(t, errText, "provision failed")
require.NotContains(t, errText, "ya29.secret-value")
}
func TestModuleHandlerListEmptySnapshot(t *testing.T) {
w, envelope := performModuleList(t, &moduleStatusSourceStub{})
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, envelope.Data.Modules)
require.Empty(t, envelope.Data.Modules)
require.Contains(t, w.Body.String(), `"modules":[]`)
}
type moduleHandlerFakeModule struct {
id plugin.ModuleID
enabledByDefault bool
}
func (m *moduleHandlerFakeModule) ModuleInfo() plugin.ModuleInfo {
id := m.id
enabled := m.enabledByDefault
return plugin.ModuleInfo{
ID: id,
New: func() plugin.Module { return &moduleHandlerFakeModule{id: id, enabledByDefault: enabled} },
EnabledByDefault: enabled,
}
}
// TestModuleHandlerListWithRuntimeSnapshot 用真实 Runtime(隔离注册表)验证
// 接口返回与 Runtime.Snapshot 一致:enabled 模块为 running,
// disabled 模块也在列且 state=registered、enabled=false。
func TestModuleHandlerListWithRuntimeSnapshot(t *testing.T) {
registry := plugin.NewRegistry()
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.enabled", enabledByDefault: true})
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.disabled", enabledByDefault: false})
runtime := plugin.NewRuntimeWithRegistry(nil, nil, registry)
require.NoError(t, runtime.Build())
require.NoError(t, runtime.Start(context.Background()))
t.Cleanup(func() { _ = runtime.Stop(context.Background()) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/admin/modules", NewModuleHandler(runtime).List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
require.Len(t, envelope.Data.Modules, 2)
disabled := envelope.Data.Modules[0]
require.Equal(t, "job.disabled", disabled.ID)
require.False(t, disabled.Enabled)
require.Equal(t, "registered", disabled.State)
enabled := envelope.Data.Modules[1]
require.Equal(t, "job.enabled", enabled.ID)
require.True(t, enabled.Enabled)
require.Equal(t, "running", enabled.State)
}