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 is a minimal Streamable HTTP MCP server for the plugin tests. // wantAuth, when set, requires the bearer header; tools is mutated atomically so // a refresh test can change the tool list. 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 is a gateway wired with a real plugin manager over a temp // 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 returns a fake server plus a setter for its tool list. 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) } }) } }