主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
302 lines
9.9 KiB
Go
302 lines
9.9 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
|
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
|
)
|
|
|
|
// fakeMCPHandler 是供插件测试用的最小 Streamable HTTP MCP server。
|
|
// wantAuth 设置后要求 bearer 头;tools 以原子方式变更,
|
|
// 使刷新测试能改变工具列表。
|
|
type fakeMCPHandler struct {
|
|
wantAuth string
|
|
tools []string
|
|
}
|
|
|
|
func (f *fakeMCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
<-r.Context().Done()
|
|
return
|
|
}
|
|
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
var req struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
_ = json.Unmarshal(body, &req)
|
|
if len(req.ID) == 0 || string(req.ID) == "null" {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
var result any
|
|
switch req.Method {
|
|
case "initialize":
|
|
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "fake", "version": "1.0"}}
|
|
case "tools/list":
|
|
list := make([]map[string]any, 0, len(f.tools))
|
|
for _, name := range f.tools {
|
|
list = append(list, map[string]any{"name": name, "description": name, "inputSchema": map[string]any{"type": "object"}})
|
|
}
|
|
result = map[string]any{"tools": list}
|
|
default:
|
|
result = map[string]any{}
|
|
}
|
|
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(resp)
|
|
}
|
|
|
|
// pluginTestEnv 是一个网关,接线了以临时 plugins.json 为
|
|
// 后端的真实插件管理器。
|
|
type pluginTestEnv struct {
|
|
ts *httptest.Server
|
|
caps *capreg.Registry
|
|
path string
|
|
token string
|
|
mcpURL string
|
|
}
|
|
|
|
func newPluginTestEnv(t *testing.T, mcp *fakeMCPHandler) *pluginTestEnv {
|
|
t.Helper()
|
|
mcpURL := ""
|
|
if mcp != nil {
|
|
ts := httptest.NewServer(mcp)
|
|
t.Cleanup(ts.Close)
|
|
mcpURL = ts.URL
|
|
}
|
|
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
|
caps := capreg.New()
|
|
path := filepath.Join(t.TempDir(), "plugins.json")
|
|
mgr := pluginmgr.NewManager(caps, path, nil)
|
|
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
|
srv, err := New(cfg, Deps{Auth: jwt, Bus: bus.New(), Registry: caps, Plugins: mgr})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(func() {
|
|
ts.Close()
|
|
mgr.Close(context.Background())
|
|
})
|
|
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return &pluginTestEnv{ts: ts, caps: caps, path: path, token: tok, mcpURL: mcpURL}
|
|
}
|
|
|
|
// newFakeMCPHandler 返回一个假 server 以及其工具列表的 setter。
|
|
func newFakeMCPHandler(wantAuth string, tools ...string) *fakeMCPHandler {
|
|
return &fakeMCPHandler{wantAuth: wantAuth, tools: tools}
|
|
}
|
|
|
|
func installedTools(reg *capreg.Registry) map[string]bool {
|
|
out := make(map[string]bool)
|
|
for _, c := range reg.List(capreg.KindTool) {
|
|
out[c.Name] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAdminPluginInstall_RegistersToolsPersistsAndMasks(t *testing.T) {
|
|
t.Parallel()
|
|
mcp := newFakeMCPHandler("sk-1234", "add", "mul")
|
|
env := newPluginTestEnv(t, mcp)
|
|
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
|
"kind": "mcp",
|
|
"name": "calc",
|
|
"config": map[string]any{
|
|
"transport": "http",
|
|
"endpoint": env.mcpURL,
|
|
"apiKey": "sk-1234",
|
|
},
|
|
}, env.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Installed bool `json:"installed"`
|
|
Name string `json:"name"`
|
|
Transport string `json:"transport"`
|
|
ToolCount int `json:"toolCount"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
t.Fatalf("decode: %v (%s)", err, raw)
|
|
}
|
|
if !envelope.Data.Installed || envelope.Data.ToolCount != 2 || envelope.Data.Transport != "http" {
|
|
t.Fatalf("install data = %+v", envelope.Data)
|
|
}
|
|
names := installedTools(env.caps)
|
|
if !names["add"] || !names["mul"] {
|
|
t.Fatalf("registered tools = %v", names)
|
|
}
|
|
if _, err := env.caps.Get("mcp:calc"); err != nil {
|
|
t.Fatalf("mcp:calc capability: %v", err)
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
|
if err != nil || len(persisted) != 1 || persisted[0].APIKey != "sk-1234" {
|
|
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
|
}
|
|
|
|
resp, raw = doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins/installed", nil, env.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, body = %s", resp.StatusCode, raw)
|
|
}
|
|
if strings.Contains(string(raw), "sk-1234") {
|
|
t.Fatalf("installed list leaks the API key: %s", raw)
|
|
}
|
|
var listEnv struct {
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &listEnv); err != nil {
|
|
t.Fatalf("decode list: %v (%s)", err, raw)
|
|
}
|
|
if len(listEnv.Data) != 1 {
|
|
t.Fatalf("list = %+v, want one entry", listEnv.Data)
|
|
}
|
|
if got := listEnv.Data[0]["apiKey"]; got != "****1234" {
|
|
t.Fatalf("masked apiKey = %v, want ****1234", got)
|
|
}
|
|
if got := listEnv.Data[0]["live"]; got != true {
|
|
t.Fatalf("live = %v, want true", got)
|
|
}
|
|
}
|
|
|
|
func TestAdminPluginUninstall_RemovesToolsAndConfig(t *testing.T) {
|
|
t.Parallel()
|
|
mcp := newFakeMCPHandler("", "add")
|
|
env := newPluginTestEnv(t, mcp)
|
|
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
|
"name": "calc",
|
|
"config": map[string]any{"endpoint": env.mcpURL},
|
|
}, env.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
|
|
}
|
|
|
|
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "calc"}, env.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Uninstalled bool `json:"uninstalled"`
|
|
Kind string `json:"kind"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
t.Fatalf("decode: %v (%s)", err, raw)
|
|
}
|
|
if !envelope.Data.Uninstalled || envelope.Data.Kind != runtimeconfig.PluginKindMCP {
|
|
t.Fatalf("uninstall data = %+v", envelope.Data)
|
|
}
|
|
if names := installedTools(env.caps); len(names) != 0 {
|
|
t.Fatalf("tools after uninstall = %v", names)
|
|
}
|
|
if _, err := env.caps.Get("mcp:calc"); err == nil {
|
|
t.Fatal("mcp:calc capability still registered")
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
|
if err != nil || len(persisted) != 0 {
|
|
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
|
}
|
|
}
|
|
|
|
func TestAdminPluginInstall_ConnectFailureReturns502NoPersist(t *testing.T) {
|
|
t.Parallel()
|
|
dead := httptest.NewServer(http.NotFoundHandler())
|
|
url := dead.URL
|
|
dead.Close()
|
|
env := newPluginTestEnv(t, nil)
|
|
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
|
"name": "down",
|
|
"config": map[string]any{"endpoint": url},
|
|
}, env.token)
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502, body = %s", resp.StatusCode, raw)
|
|
}
|
|
if names := installedTools(env.caps); len(names) != 0 {
|
|
t.Fatalf("tools = %v, want none", names)
|
|
}
|
|
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
|
if err != nil || len(persisted) != 0 {
|
|
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
|
}
|
|
}
|
|
|
|
func TestAdminPluginUninstall_FallsBackToCapabilityRemoval(t *testing.T) {
|
|
t.Parallel()
|
|
env := newPluginTestEnv(t, nil)
|
|
if err := env.caps.RegisterTool(stubTool{}); err != nil {
|
|
t.Fatalf("register tool: %v", err)
|
|
}
|
|
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "echo"}, env.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", resp.StatusCode, raw)
|
|
}
|
|
if _, err := env.caps.Get("echo"); err == nil {
|
|
t.Fatal("echo capability still registered")
|
|
}
|
|
resp, _ = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "nope"}, env.token)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("unknown status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAdminPluginInstall_InvalidRequestsReturn400(t *testing.T) {
|
|
t.Parallel()
|
|
env := newPluginTestEnv(t, newFakeMCPHandler(""))
|
|
cases := []struct {
|
|
name string
|
|
body any
|
|
raw string
|
|
}{
|
|
{name: "no name", body: map[string]any{"config": map[string]any{"command": "x"}}},
|
|
{name: "bad kind", body: map[string]any{"kind": "tool", "name": "x", "config": map[string]any{"command": "x"}}},
|
|
{name: "no transport source", body: map[string]any{"name": "x", "config": map[string]any{}}},
|
|
{name: "stdio no command", body: map[string]any{"name": "x", "config": map[string]any{"transport": "stdio"}}},
|
|
{name: "bad transport", body: map[string]any{"name": "x", "config": map[string]any{"transport": "pigeon", "endpoint": "http://x"}}},
|
|
{name: "malformed json", raw: "{"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var resp *http.Response
|
|
var raw []byte
|
|
if tc.raw != "" {
|
|
resp, raw = doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", tc.raw, env.token)
|
|
} else {
|
|
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", tc.body, env.token)
|
|
}
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
|
|
}
|
|
})
|
|
}
|
|
}
|