diff --git a/workspace-server/internal/handlers/plugins_listing_test.go b/workspace-server/internal/handlers/plugins_listing_test.go new file mode 100644 index 000000000..3db639fc3 --- /dev/null +++ b/workspace-server/internal/handlers/plugins_listing_test.go @@ -0,0 +1,472 @@ +package handlers + +// Unit tests for plugins_listing.go: +// - parseManifestYAML: full YAML, missing fields, empty YAML +// - listRegistryFiltered: empty/missing dir, no yaml, valid yaml, runtime filter +// - ListRegistry (GET /plugins): no filter, with runtime filter +// - ListAvailableForWorkspace (GET /workspaces/:id/plugins/available): runtimeLookup stub + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +// -------- parseManifestYAML -------- + +func TestParseManifestYAML_FullPlugin(t *testing.T) { + data := []byte(` +name: molecule-audit +version: 1.2.3 +description: Security audit plugin for Claude Code +author: Molecule AI +tags: + - security + - audit +skills: + - security-scan + - compliance-check +runtimes: + - claude_code + - hermes +`) + info := parseManifestYAML("fallback-name", data) + if info.Name != "fallback-name" { + t.Errorf("Name = %q; want fallback-name", info.Name) + } + if info.Version != "1.2.3" { + t.Errorf("Version = %q; want 1.2.3", info.Version) + } + if info.Description != "Security audit plugin for Claude Code" { + t.Errorf("Description = %q; want full description", info.Description) + } + if info.Author != "Molecule AI" { + t.Errorf("Author = %q; want Molecule AI", info.Author) + } + if len(info.Tags) != 2 || info.Tags[0] != "security" || info.Tags[1] != "audit" { + t.Errorf("Tags = %v; want [security audit]", info.Tags) + } + if len(info.Skills) != 2 || info.Skills[0] != "security-scan" || info.Skills[1] != "compliance-check" { + t.Errorf("Skills = %v; want [security-scan compliance-check]", info.Skills) + } + if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "hermes" { + t.Errorf("Runtimes = %v; want [claude_code hermes]", info.Runtimes) + } +} + +func TestParseManifestYAML_MinimalFields(t *testing.T) { + // Only name field; all others should be zero-value. + data := []byte(`name: minimal-plugin`) + info := parseManifestYAML("fallback", data) + if info.Name != "fallback" { + t.Errorf("Name = %q; want fallback", info.Name) + } + if info.Version != "" { + t.Errorf("Version = %q; want empty", info.Version) + } + if info.Description != "" { + t.Errorf("Description = %q; want empty", info.Description) + } + if len(info.Tags) != 0 { + t.Errorf("Tags = %v; want []", info.Tags) + } + if len(info.Skills) != 0 { + t.Errorf("Skills = %v; want []", info.Skills) + } + if len(info.Runtimes) != 0 { + t.Errorf("Runtimes = %v; want []", info.Runtimes) + } +} + +func TestParseManifestYAML_MissingPluginYAML(t *testing.T) { + // No plugin.yaml present → returns fallback name only. + info := parseManifestYAML("no-file", nil) + if info.Name != "no-file" { + t.Errorf("Name = %q; want no-file", info.Name) + } +} + +func TestParseManifestYAML_BadYAML(t *testing.T) { + // Malformed YAML → returns fallback name only (no panic). + info := parseManifestYAML("bad-yaml", []byte("not: [yaml: at all")) + if info.Name != "bad-yaml" { + t.Errorf("Name = %q; want bad-yaml", info.Name) + } + if info.Version != "" { + t.Errorf("Version = %q; want empty after bad YAML", info.Version) + } +} + +func TestParseManifestYAML_PartialFields(t *testing.T) { + // Present tags/skills/runtimes that are not []interface{} (e.g. wrong type) + // should not panic and should leave the field empty. + data := []byte(` +name: partial +tags: "not-an-array" +skills: 123 +runtimes: true +`) + info := parseManifestYAML("partial", data) + if info.Name != "partial" { + t.Errorf("Name = %q; want partial", info.Name) + } + if len(info.Tags) != 0 { + t.Errorf("Tags = %v; want [] (wrong type)", info.Tags) + } + if len(info.Skills) != 0 { + t.Errorf("Skills = %v; want [] (wrong type)", info.Skills) + } + if len(info.Runtimes) != 0 { + t.Errorf("Runtimes = %v; want [] (wrong type)", info.Runtimes) + } +} + +// -------- listRegistryFiltered -------- + +func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler { + // Construct a minimal PluginsHandler with a nil docker client + // (filesystem paths are tested directly; container-dependent paths are + // tested separately or skipped in this file). + h := &PluginsHandler{pluginsDir: pluginsDir} + return h +} + +func writePluginYAML(t *testing.T, dir, name, content string) { + path := filepath.Join(dir, name, "plugin.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writeFile: %v", err) + } +} + +func TestListRegistryFiltered_EmptyDir(t *testing.T) { + dir := t.TempDir() + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("") + if len(got) != 0 { + t.Errorf("expected empty list for empty dir; got %d plugins", len(got)) + } +} + +func TestListRegistryFiltered_NonExistentDir(t *testing.T) { + h := makeTestHandler(t, "/does/not/exist") + got := h.listRegistryFiltered("") + if len(got) != 0 { + t.Errorf("expected empty list for nonexistent dir; got %d plugins", len(got)) + } +} + +func TestListRegistryFiltered_NoPluginYAML(t *testing.T) { + // Plugin directory exists but has no plugin.yaml → fallback name only. + dir := t.TempDir() + writePluginYAML(t, dir, "no-manifest-plugin", "") + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("") + if len(got) != 1 { + t.Fatalf("expected 1 plugin; got %d", len(got)) + } + if got[0].Name != "no-manifest-plugin" { + t.Errorf("Name = %q; want no-manifest-plugin", got[0].Name) + } +} + +func TestListRegistryFiltered_ValidPlugin(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "molecule-audit", ` +name: molecule-audit +version: 1.0.0 +description: Security audit plugin +author: Molecule AI +tags: + - security +skills: + - audit +runtimes: + - hermes +`) + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("") + if len(got) != 1 { + t.Fatalf("expected 1 plugin; got %d", len(got)) + } + if got[0].Name != "molecule-audit" { + t.Errorf("Name = %q; want molecule-audit", got[0].Name) + } + if got[0].Version != "1.0.0" { + t.Errorf("Version = %q; want 1.0.0", got[0].Version) + } + if len(got[0].Tags) != 1 || got[0].Tags[0] != "security" { + t.Errorf("Tags = %v; want [security]", got[0].Tags) + } +} + +func TestListRegistryFiltered_FilesIgnored(t *testing.T) { + // Regular files in pluginsDir are skipped (only directories are scanned). + dir := t.TempDir() + writePluginYAML(t, dir, "real-plugin", ` +name: real-plugin +version: 1.0.0 +`) + f, err := os.Create(filepath.Join(dir, "not-a-plugin.txt")) + if err != nil { + t.Fatal(err) + } + f.Close() + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("") + if len(got) != 1 || got[0].Name != "real-plugin" { + t.Errorf("expected only real-plugin; got %v", got) + } +} + +func TestListRegistryFiltered_RuntimeFilterMatches(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "cc-plugin", ` +name: cc-plugin +runtimes: [claude_code] +`) + writePluginYAML(t, dir, "hermes-plugin", ` +name: hermes-plugin +runtimes: [hermes] +`) + h := makeTestHandler(t, dir) + + // With hermes filter → only hermes-plugin returned. + got := h.listRegistryFiltered("hermes") + if len(got) != 1 || got[0].Name != "hermes-plugin" { + t.Errorf("expected [hermes-plugin]; got %v", got) + } + + // With claude-code filter → hyphen normalises to underscore → cc-plugin returned. + got2 := h.listRegistryFiltered("claude-code") + if len(got2) != 1 || got2[0].Name != "cc-plugin" { + t.Errorf("expected [cc-plugin] with claude-code filter; got %v", got2) + } +} + +func TestListRegistryFiltered_RuntimeFilterExcludes(t *testing.T) { + // Plugin declares hermes; query asks for claude-code → plugin excluded. + dir := t.TempDir() + writePluginYAML(t, dir, "hermes-only", ` +name: hermes-only +runtimes: [hermes] +`) + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("claude_code") + if len(got) != 0 { + t.Errorf("expected empty list for mismatched runtime; got %v", got) + } +} + +func TestListRegistryFiltered_UnspecifiedRuntimeIncluded(t *testing.T) { + // Plugin with no runtimes field is included in any filtered query + // ("unspecified = try it" contract). + dir := t.TempDir() + writePluginYAML(t, dir, "universal-plugin", ` +name: universal-plugin +runtimes: [] +`) + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("any-runtime") + if len(got) != 1 || got[0].Name != "universal-plugin" { + t.Errorf("expected [universal-plugin] with any runtime filter; got %v", got) + } +} + +func TestListRegistryFiltered_MultipleMatching(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"plugin-a", "plugin-b", "plugin-c"} { + writePluginYAML(t, dir, name, `name: `+name+` +runtimes: [hermes, claude_code] +`) + } + h := makeTestHandler(t, dir) + got := h.listRegistryFiltered("hermes") + if len(got) != 3 { + t.Errorf("expected 3 plugins; got %d: %v", len(got), got) + } +} + +// -------- ListRegistry (GET /plugins) -------- + +func listRegistryReq(runtime string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) { + url := "/plugins" + if runtime != "" { + url += "?runtime=" + runtime + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", url, nil) + return c.Request, w, c +} + +func TestListRegistry_NoFilter(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "test-plugin", ` +name: test-plugin +version: 0.1.0 +`) + h := makeTestHandler(t, dir) + _, w, c := listRegistryReq("") + h.ListRegistry(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d: %s", w.Code, w.Body.String()) + } + var resp []map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp) != 1 || resp[0]["name"] != "test-plugin" { + t.Errorf("unexpected response: %v", resp) + } +} + +func TestListRegistry_WithRuntimeFilter(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "hermes-plugin", ` +name: hermes-plugin +runtimes: [hermes] +`) + writePluginYAML(t, dir, "cc-plugin", ` +name: cc-plugin +runtimes: [claude_code] +`) + h := makeTestHandler(t, dir) + _, w, c := listRegistryReq("hermes") + h.ListRegistry(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d", w.Code) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" { + t.Errorf("expected [hermes-plugin]; got %v", resp) + } +} + +func TestListRegistry_EmptyOnNoMatches(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "cc-plugin", `name: cc-plugin +runtimes: [claude_code] +`) + h := makeTestHandler(t, dir) + _, w, c := listRegistryReq("nonexistent") + h.ListRegistry(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d", w.Code) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 0 { + t.Errorf("expected empty list; got %v", resp) + } +} + +// -------- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available) -------- + +func listAvailableReq(workspaceID string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: workspaceID}} + c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil) + return c.Request, w, c +} + +func TestListAvailableForWorkspace_RuntimeLookupReturnsRuntime(t *testing.T) { + dir := t.TempDir() + writePluginYAML(t, dir, "hermes-plugin", ` +name: hermes-plugin +runtimes: [hermes] +`) + writePluginYAML(t, dir, "cc-plugin", ` +name: cc-plugin +runtimes: [claude_code] +`) + h := makeTestHandler(t, dir) + h.runtimeLookup = func(workspaceID string) (string, error) { + return "hermes", nil + } + _, w, c := listAvailableReq("00000000-0000-0000-0000-000000000001") + h.ListAvailableForWorkspace(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d", w.Code) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" { + t.Errorf("expected [hermes-plugin]; got %v", resp) + } +} + +func TestListAvailableForWorkspace_RuntimeLookupErrors(t *testing.T) { + // runtimeLookup error → runtime="" → full registry returned. + dir := t.TempDir() + writePluginYAML(t, dir, "plugin-a", `name: plugin-a +runtimes: [hermes] +`) + writePluginYAML(t, dir, "plugin-b", `name: plugin-b +runtimes: [claude_code] +`) + h := makeTestHandler(t, dir) + h.runtimeLookup = func(workspaceID string) (string, error) { + return "", errors.New("runtime lookup failed") + } + _, w, c := listAvailableReq("00000000-0000-0000-0000-000000000002") + h.ListAvailableForWorkspace(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d", w.Code) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 2 { + t.Errorf("expected 2 plugins (full registry fallback); got %d: %v", len(resp), resp) + } +} + +func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) { + // runtimeLookup nil → full registry (no filter). + dir := t.TempDir() + writePluginYAML(t, dir, "plugin-x", `name: plugin-x`) + h := makeTestHandler(t, dir) + // runtimeLookup is nil by default from makeTestHandler. + _, w, c := listAvailableReq("00000000-0000-0000-0000-000000000003") + h.ListAvailableForWorkspace(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200; got %d", w.Code) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 1 || resp[0]["name"] != "plugin-x" { + t.Errorf("expected [plugin-x]; got %v", resp) + } +} + +func TestListAvailableForWorkspace_UnspecifiedRuntimePluginsAlwaysIncluded(t *testing.T) { + // Plugins with empty runtimes list should always be included + // regardless of workspace runtime. + dir := t.TempDir() + writePluginYAML(t, dir, "universal", `name: universal +runtimes: [] +`) + writePluginYAML(t, dir, "cc-only", `name: cc-only +runtimes: [claude_code] +`) + h := makeTestHandler(t, dir) + h.runtimeLookup = func(id string) (string, error) { return "hermes", nil } + _, w, c := listAvailableReq("ws-001") + h.ListAvailableForWorkspace(c) + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + // "universal" has no runtimes (try-it); "cc-only" doesn't support hermes. + if len(resp) != 1 || resp[0]["name"] != "universal" { + t.Errorf("expected [universal]; got %v", resp) + } +}