package handlers import ( "archive/tar" "bytes" "compress/gzip" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" ) // ---------- ListRegistry: empty dir → 200 [] ---------- func TestPluginListRegistry_EmptyDir(t *testing.T) { dir := t.TempDir() h := NewPluginsHandler(dir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/plugins", nil) h.ListRegistry(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(plugins) != 0 { t.Errorf("expected 0 plugins, got %d", len(plugins)) } } // ---------- ListRegistry: non-existent dir → 200 [] ---------- func TestPluginListRegistry_NonExistentDir(t *testing.T) { h := NewPluginsHandler("/tmp/does-not-exist-plugins-xyz", nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/plugins", nil) h.ListRegistry(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(plugins) != 0 { t.Errorf("expected 0 plugins, got %d", len(plugins)) } } // ---------- ListRegistry: with plugins → returns manifest data ---------- func TestPluginListRegistry_WithPlugins(t *testing.T) { dir := t.TempDir() // Create a plugin with manifest pluginDir := filepath.Join(dir, "my-plugin") if err := os.Mkdir(pluginDir, 0755); err != nil { t.Fatal(err) } manifest := `name: my-plugin version: "1.0.0" description: A test plugin author: tester tags: - test - example skills: - greet ` if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(manifest), 0644); err != nil { t.Fatal(err) } // Create a plugin without manifest (just a directory) bareDir := filepath.Join(dir, "bare-plugin") if err := os.Mkdir(bareDir, 0755); err != nil { t.Fatal(err) } // Create a regular file (should be skipped — not a directory) if err := os.WriteFile(filepath.Join(dir, "not-a-dir.txt"), []byte("hi"), 0644); err != nil { t.Fatal(err) } h := NewPluginsHandler(dir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/plugins", nil) h.ListRegistry(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(plugins) != 2 { t.Fatalf("expected 2 plugins, got %d", len(plugins)) } // Find the manifest plugin (order depends on readdir) var found bool for _, p := range plugins { if p.Name == "my-plugin" { found = true if p.Version != "1.0.0" { t.Errorf("expected version 1.0.0, got %s", p.Version) } if p.Description != "A test plugin" { t.Errorf("expected description 'A test plugin', got %s", p.Description) } if p.Author != "tester" { t.Errorf("expected author 'tester', got %s", p.Author) } if len(p.Tags) != 2 || p.Tags[0] != "test" || p.Tags[1] != "example" { t.Errorf("unexpected tags: %v", p.Tags) } if len(p.Skills) != 1 || p.Skills[0] != "greet" { t.Errorf("unexpected skills: %v", p.Skills) } } if p.Name == "bare-plugin" { if p.Version != "" { t.Errorf("bare plugin should have empty version, got %s", p.Version) } } } if !found { t.Error("my-plugin not found in results") } } // ---------- Install: missing source → 400 ---------- func TestPluginInstall_MissingSource(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-123"}} c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(`{}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } } // ---------- Install: invalid name in local source (path traversal) → 400 ---------- func TestPluginInstall_InvalidName_PathTraversal(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-123"}} body := `{"source":"local://../../../etc/passwd"}` c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(body)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } } // ---------- Install: plugin not found → 404 ---------- func TestPluginInstall_NotFound(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-123"}} body := `{"source":"local://nonexistent-plugin"}` c.Request = httptest.NewRequest("POST", "/workspaces/ws-123/plugins", bytes.NewBufferString(body)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) } } // ---------- Uninstall: invalid name → 400 ---------- func TestPluginUninstall_InvalidName(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "ws-123"}, {Key: "name", Value: "../escape"}, } c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/plugins/../escape", nil) h.Uninstall(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } } // ---------- Uninstall: empty name → 400 ---------- func TestPluginUninstall_EmptyName(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "ws-123"}, {Key: "name", Value: ""}, } c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/plugins/", nil) h.Uninstall(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) } } // ---------- validatePluginName: valid names pass ---------- func TestValidatePluginName_ValidNames(t *testing.T) { valid := []string{ "my-plugin", "plugin_v2", "AwesomePlugin", "plugin123", "a", } for _, name := range valid { if err := validatePluginName(name); err != nil { t.Errorf("validatePluginName(%q) should pass, got: %v", name, err) } } } // ---------- validatePluginName: "/" rejected ---------- func TestValidatePluginName_SlashRejected(t *testing.T) { names := []string{ "foo/bar", "/leading", "trailing/", "a/b/c", } for _, name := range names { if err := validatePluginName(name); err == nil { t.Errorf("validatePluginName(%q) should fail for slash", name) } } } // ---------- validatePluginName: ".." rejected ---------- func TestValidatePluginName_DotDotRejected(t *testing.T) { names := []string{ "..", "..foo", "foo..", "a..b", } for _, name := range names { if err := validatePluginName(name); err == nil { t.Errorf("validatePluginName(%q) should fail for '..'", name) } } } // ---------- validatePluginName: backslash rejected ---------- func TestValidatePluginName_BackslashRejected(t *testing.T) { if err := validatePluginName(`foo\bar`); err == nil { t.Error(`validatePluginName("foo\\bar") should fail`) } } // ---------- validatePluginName: empty rejected ---------- func TestValidatePluginName_EmptyRejected(t *testing.T) { if err := validatePluginName(""); err == nil { t.Error("validatePluginName(\"\") should fail") } } // ---------- parseManifestYAML: valid yaml → correct pluginInfo ---------- func TestParseManifestYAML_ValidYAML(t *testing.T) { yaml := []byte(` name: test-plugin version: "2.0.0" description: "Does things" author: "dev" tags: - utility - automation skills: - summarize - translate `) info := parseManifestYAML("fallback-name", yaml) // Name should use fallbackName, not the yaml name field if info.Name != "fallback-name" { t.Errorf("expected name 'fallback-name', got %s", info.Name) } if info.Version != "2.0.0" { t.Errorf("expected version 2.0.0, got %s", info.Version) } if info.Description != "Does things" { t.Errorf("expected description 'Does things', got %s", info.Description) } if info.Author != "dev" { t.Errorf("expected author 'dev', got %s", info.Author) } if len(info.Tags) != 2 || info.Tags[0] != "utility" || info.Tags[1] != "automation" { t.Errorf("unexpected tags: %v", info.Tags) } if len(info.Skills) != 2 || info.Skills[0] != "summarize" || info.Skills[1] != "translate" { t.Errorf("unexpected skills: %v", info.Skills) } } // ---------- parseManifestYAML: invalid yaml → fallback ---------- func TestParseManifestYAML_InvalidYAML(t *testing.T) { info := parseManifestYAML("safe-name", []byte(`{{{not valid yaml`)) if info.Name != "safe-name" { t.Errorf("expected fallback name 'safe-name', got %s", info.Name) } if info.Version != "" { t.Errorf("expected empty version on invalid yaml, got %s", info.Version) } } // ---------- parseManifestYAML: minimal yaml (no tags/skills) ---------- func TestParseManifestYAML_MinimalYAML(t *testing.T) { yaml := []byte(`version: "0.1"`) info := parseManifestYAML("minimal", yaml) if info.Name != "minimal" { t.Errorf("expected name 'minimal', got %s", info.Name) } if info.Version != "0.1" { t.Errorf("expected version '0.1', got %s", info.Version) } if info.Tags != nil { t.Errorf("expected nil tags, got %v", info.Tags) } if info.Skills != nil { t.Errorf("expected nil skills, got %v", info.Skills) } } // ---------- Runtime filter on ListRegistry ---------- // writePlugin is a small helper for the runtime-filter tests. func writePlugin(t *testing.T, dir, name, manifest string) { t.Helper() if err := os.MkdirAll(filepath.Join(dir, name), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, name, "plugin.yaml"), []byte(manifest), 0644); err != nil { t.Fatal(err) } } func TestPluginListRegistry_FiltersByRuntime(t *testing.T) { dir := t.TempDir() writePlugin(t, dir, "p-cc", "name: p-cc\nruntimes: [claude_code]\n") writePlugin(t, dir, "p-da", "name: p-da\nruntimes: [deepagents]\n") writePlugin(t, dir, "p-both", "name: p-both\nruntimes: [claude_code, deepagents]\n") writePlugin(t, dir, "p-legacy", "name: p-legacy\n") // no runtimes — always allowed h := NewPluginsHandler(dir, nil, nil) cases := []struct { name string runtime string expected map[string]bool }{ {"no filter returns all", "", map[string]bool{"p-cc": true, "p-da": true, "p-both": true, "p-legacy": true}}, {"claude_code filter", "claude_code", map[string]bool{"p-cc": true, "p-both": true, "p-legacy": true}}, {"deepagents filter", "deepagents", map[string]bool{"p-da": true, "p-both": true, "p-legacy": true}}, {"hyphen form normalized", "claude-code", map[string]bool{"p-cc": true, "p-both": true, "p-legacy": true}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) url := "/plugins" if tc.runtime != "" { url += "?runtime=" + tc.runtime } c.Request = httptest.NewRequest("GET", url, nil) h.ListRegistry(c) var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatalf("unmarshal: %v", err) } got := map[string]bool{} for _, p := range plugins { got[p.Name] = true } if len(got) != len(tc.expected) { t.Errorf("runtime=%q: got %v, want %v", tc.runtime, got, tc.expected) } for name := range tc.expected { if !got[name] { t.Errorf("runtime=%q: missing %q", tc.runtime, name) } } }) } } // ---------- ListAvailableForWorkspace ---------- func TestPluginListAvailableForWorkspace_UsesRuntimeLookup(t *testing.T) { dir := t.TempDir() writePlugin(t, dir, "only-deepagents", "name: only-deepagents\nruntimes: [deepagents]\n") writePlugin(t, dir, "only-claude", "name: only-claude\nruntimes: [claude_code]\n") // Workspace resolves to deepagents. h := NewPluginsHandler(dir, nil, nil).WithRuntimeLookup(func(id string) (string, error) { if id == "ws-da" { return "deepagents", nil } return "claude_code", nil }) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-da"}} c.Request = httptest.NewRequest("GET", "/workspaces/ws-da/plugins/available", nil) h.ListAvailableForWorkspace(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatal(err) } if len(plugins) != 1 || plugins[0].Name != "only-deepagents" { t.Errorf("expected only-deepagents, got %+v", plugins) } } func TestPluginListAvailableForWorkspace_NoLookupReturnsAll(t *testing.T) { dir := t.TempDir() writePlugin(t, dir, "only-deepagents", "name: only-deepagents\nruntimes: [deepagents]\n") writePlugin(t, dir, "only-claude", "name: only-claude\nruntimes: [claude_code]\n") // No runtime lookup wired → falls back to full registry. h := NewPluginsHandler(dir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "anything"}} c.Request = httptest.NewRequest("GET", "/workspaces/anything/plugins/available", nil) h.ListAvailableForWorkspace(c) var plugins []pluginInfo if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil { t.Fatal(err) } if len(plugins) != 2 { t.Errorf("expected 2 plugins, got %d", len(plugins)) } } // ---------- Manifest parsing: runtimes field ---------- func TestParseManifestYAML_PicksUpRuntimes(t *testing.T) { info := parseManifestYAML("demo", []byte("name: demo\nruntimes:\n - claude_code\n - deepagents\n")) if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "deepagents" { t.Errorf("expected [claude_code, deepagents], got %v", info.Runtimes) } if !info.supportsRuntime("claude-code") { t.Error("hyphen/underscore normalization broken") } if info.supportsRuntime("langgraph") { t.Error("should not support langgraph") } } func TestSupportsRuntime_EmptyMeansLegacy(t *testing.T) { info := pluginInfo{Name: "legacy"} if !info.supportsRuntime("anything") { t.Error("legacy plugins (no runtimes field) must be treated as compatible") } } // ---------- CheckRuntimeCompatibility ---------- func TestCheckRuntimeCompatibility_RejectsMissingRuntimeParam(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("GET", "/workspaces/ws/plugins/compatibility", nil) h.CheckRuntimeCompatibility(c) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCheckRuntimeCompatibility_TriviallyCompatibleWhenContainerMissing(t *testing.T) { // No docker client + no container → treated as "nothing installed, all compatible". h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("GET", "/workspaces/ws/plugins/compatibility?runtime=deepagents", nil) h.CheckRuntimeCompatibility(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var body map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body["all_compatible"] != true { t.Errorf("expected all_compatible=true, got %v", body["all_compatible"]) } if body["target_runtime"] != "deepagents" { t.Errorf("target_runtime mismatch: %v", body["target_runtime"]) } } // ---------- ListSources ---------- func TestPluginListSources_ReturnsRegisteredSchemes(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/plugins/sources", nil) h.ListSources(c) if w.Code != http.StatusOK { t.Fatalf("status=%d", w.Code) } var body map[string][]string if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } hasLocal, hasGithub := false, false for _, s := range body["schemes"] { if s == "local" { hasLocal = true } if s == "github" { hasGithub = true } } if !hasLocal || !hasGithub { t.Errorf("expected local+github by default, got %v", body["schemes"]) } } // ---------- Install — source routing ---------- func TestPluginInstall_RejectsEmptyBody(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) } } func TestPluginInstall_RejectsUnknownScheme(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"mystery://thing"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) } if !bytes.Contains(w.Body.Bytes(), []byte("available_schemes")) { t.Errorf("response should list available_schemes: %s", w.Body.String()) } } func TestPluginInstall_LocalSourceReachesContainerLookup(t *testing.T) { base := t.TempDir() pluginDir := filepath.Join(base, "demo") _ = os.MkdirAll(pluginDir, 0o755) _ = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte("name: demo\n"), 0o644) h := NewPluginsHandler(base, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"local://demo"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) // No docker client configured → source resolves, stage succeeds, then // 503 on container lookup. Proves the local dispatch + stage worked. if w.Code != http.StatusServiceUnavailable { t.Errorf("local:// should reach container lookup: got %d: %s", w.Code, w.Body.String()) } } func TestPluginInstall_InvalidSourceString(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":" "}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("whitespace-only source should be rejected: got %d", w.Code) } } func TestPluginInstall_RejectsOversizedBody(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) // Build a JSON body larger than the cap (default 64 KiB). big := `{"source":"local://` + strings.Repeat("a", 70*1024) + `"}` w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(big)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for oversized body, got %d", w.Code) } } // Install 404 via the typed sentinel (replaces the old string-match test). func TestPluginInstall_NotFoundUsesTypedSentinel(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws-123"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"local://nonexistent"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusNotFound { t.Errorf("expected 404 via ErrPluginNotFound, got %d: %s", w.Code, w.Body.String()) } } // Install 502 for non-sentinel resolver errors (e.g. network / auth). func TestPluginInstall_NonSentinelResolverErrorIs502(t *testing.T) { // Register a stub resolver whose Fetch returns a plain (non-ErrPluginNotFound) error. h := NewPluginsHandler(t.TempDir(), nil, nil). WithSourceResolver(alwaysErrs("broken", errors.New("connection refused"))) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"broken://whatever"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadGateway { t.Errorf("non-sentinel resolver error should be 502, got %d: %s", w.Code, w.Body.String()) } } // Install returns 504 when fetch honours ctx.DeadlineExceeded. func TestPluginInstall_DeadlineExceededIs504(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil). WithSourceResolver(alwaysErrs("slow", context.DeadlineExceeded)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"slow://x"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusGatewayTimeout { t.Errorf("deadline exceeded should be 504, got %d", w.Code) } } // Install 413 when the fetched tree exceeds the configured cap. func TestPluginInstall_OversizedStagedTreeIs413(t *testing.T) { t.Setenv("PLUGIN_INSTALL_MAX_DIR_BYTES", "1024") // 1 KiB cap h := NewPluginsHandler(t.TempDir(), nil, nil). WithSourceResolver(writesBlob("big", 2048)) // 2 KiB blob w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"big://whatever"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusRequestEntityTooLarge { t.Errorf("oversized staged tree should be 413, got %d: %s", w.Code, w.Body.String()) } } // envDuration / envInt64 moved to platform/internal/envx; see // envx/envx_test.go for their tests. func TestDirSize_ShortCircuitsOnCap(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a"), bytes.Repeat([]byte("x"), 2048), 0o644); err != nil { t.Fatal(err) } _, err := dirSize(dir, 1024) if err == nil { t.Error("expected cap-exceeded error") } // Under the cap: no error. if _, err := dirSize(dir, 1<<20); err != nil { t.Errorf("1 MiB cap should accept 2 KiB: %v", err) } } // ---- test-only resolver stub ---- // fakeResolver is a single hook-based SourceResolver replacing the // previously-separate erroringResolver / bigBlobResolver / // hostileResolver types. Tests pick the scheme and supply a fetchFn. type fakeResolver struct { scheme string fetchFn func(ctx context.Context, spec, dst string) (string, error) } func (f *fakeResolver) Scheme() string { return f.scheme } func (f *fakeResolver) Fetch(ctx context.Context, spec, dst string) (string, error) { return f.fetchFn(ctx, spec, dst) } // alwaysErrs returns a fakeResolver that fails every fetch with err. func alwaysErrs(scheme string, err error) *fakeResolver { return &fakeResolver{ scheme: scheme, fetchFn: func(context.Context, string, string) (string, error) { return "", err }, } } // writesBlob returns a fakeResolver that writes N bytes into dst // before returning `scheme` as the plugin name. func writesBlob(scheme string, size int) *fakeResolver { return &fakeResolver{ scheme: scheme, fetchFn: func(_ context.Context, _, dst string) (string, error) { if err := os.WriteFile(filepath.Join(dst, "blob"), bytes.Repeat([]byte("a"), size), 0o644); err != nil { return "", err } return scheme, nil }, } } // emitsName returns a fakeResolver that writes a valid plugin.yaml // but returns `returnedName` — used to probe post-fetch name // validation (e.g. hostile traversal names). func emitsName(scheme, returnedName string) *fakeResolver { return &fakeResolver{ scheme: scheme, fetchFn: func(_ context.Context, _, dst string) (string, error) { _ = os.WriteFile(filepath.Join(dst, "plugin.yaml"), []byte("name: x\n"), 0o644) return returnedName, nil }, } } func TestPluginInstall_RejectsHostileResolverPluginName(t *testing.T) { // Prove the post-fetch validatePluginName call catches a resolver // that tries to smuggle a traversal name into /configs/plugins/. h := NewPluginsHandler(t.TempDir(), nil, nil). WithSourceResolver(emitsName("hostile", "../../../etc/passwd")) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"hostile://anything"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("hostile plugin name must be 400, got %d: %s", w.Code, w.Body.String()) } } func TestPluginInstall_EmptySpecAfterSchemeRejected(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "ws"}} c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"source":"github://"}`)) c.Request.Header.Set("Content-Type", "application/json") h.Install(c) if w.Code != http.StatusBadRequest { t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) } // Handler now returns the generic "invalid plugin source" rather than // propagating the internal ParseSource "empty spec" wording — intentional // so the HTTP surface doesn't leak parser-internal vocabulary. Unit-level // coverage of the "empty spec" wording lives in plugins/source_test.go. if !bytes.Contains(w.Body.Bytes(), []byte("invalid plugin source")) { t.Errorf("error should mention 'invalid plugin source': %s", w.Body.String()) } } // ---- resolveAndStage in isolation (now testable sans gin.Context) ---- func TestResolveAndStage_HappyPath_Local(t *testing.T) { base := t.TempDir() pluginDir := filepath.Join(base, "demo") _ = os.MkdirAll(pluginDir, 0o755) _ = os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte("name: demo\n"), 0o644) h := NewPluginsHandler(base, nil, nil) res, err := h.resolveAndStage(context.Background(), installRequest{Source: "local://demo"}) if err != nil { t.Fatalf("unexpected err: %v", err) } defer os.RemoveAll(res.StagedDir) if res.PluginName != "demo" { t.Errorf("got plugin %q", res.PluginName) } if _, err := os.Stat(filepath.Join(res.StagedDir, "plugin.yaml")); err != nil { t.Errorf("plugin.yaml not staged: %v", err) } } func TestResolveAndStage_EmptyRequest(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) _, err := h.resolveAndStage(context.Background(), installRequest{}) var he *httpErr if !errors.As(err, &he) || he.Status != http.StatusBadRequest { t.Errorf("want typed httpErr with 400, got %v", err) } } func TestResolveAndStage_NotFoundFromResolver(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) // local resolver pointed at empty dir _, err := h.resolveAndStage(context.Background(), installRequest{Source: "local://absent"}) var he *httpErr if !errors.As(err, &he) || he.Status != http.StatusNotFound { t.Errorf("want 404 via ErrPluginNotFound, got %v", err) } } func TestResolveAndStage_CleansUpStagedDirOnError(t *testing.T) { // Hostile resolver emits a file then returns a traversal name → 400. // Verify the staged dir it used is NOT left behind. h := NewPluginsHandler(t.TempDir(), nil, nil). WithSourceResolver(emitsName("hostile", "../../../etc/passwd")) _, err := h.resolveAndStage(context.Background(), installRequest{Source: "hostile://x"}) var he *httpErr if !errors.As(err, &he) || he.Status != http.StatusBadRequest { t.Fatalf("expected 400, got %v", err) } // Walk /tmp for leftover "molecule-plugin-fetch-*" dirs with the // hostile resolver's marker. Probabilistic (can't pinpoint the exact // dir), but a clean cleanup means none contain the plugin.yaml blob // the resolver writes. entries, _ := filepath.Glob(filepath.Join(os.TempDir(), "molecule-plugin-fetch-*")) for _, e := range entries { if _, err := os.Stat(filepath.Join(e, "plugin.yaml")); err == nil { t.Errorf("leaked staging dir with plugin.yaml still present: %s", e) } } } func TestHTTPErr_WrapsCleanly(t *testing.T) { e := newHTTPErr(http.StatusTeapot, gin.H{"error": "I'm a teapot"}) if !strings.Contains(e.Error(), "418") { t.Errorf("Error() should include status: %q", e.Error()) } var he *httpErr if !errors.As(e, &he) || he.Status != http.StatusTeapot { t.Errorf("errors.As should extract typed error") } } func TestLogInstallLimitsOnce(t *testing.T) { // sync.Once guarantees exactly one emission per process, so this // test can't reset it. We call with a local buffer writer and assert // behaviour that holds regardless of ordering: // - first caller sees the line // - later callers see nothing // - no panic either way // If another test's NewPluginsHandler already fired the Once, our // buffer stays empty — that's correct, not a bug. var buf bytes.Buffer logInstallLimitsOnce(&buf) logInstallLimitsOnce(&buf) // must not panic; Once is idempotent if buf.Len() > 0 { // We were the first caller. Verify the line contains all three // limit names so operators can grep for them. out := buf.String() for _, want := range []string{"Plugin install limits", "body=", "timeout=", "staged="} { if !strings.Contains(out, want) { t.Errorf("log line missing %q: %s", want, out) } } } // If buf is empty: another test's NewPluginsHandler already called // Once. That's also correct behavior — nothing to assert beyond "it // didn't panic." } // ---------- regexpEscapeForAwk: special chars escaped ---------- func TestRegexpEscapeForAwk(t *testing.T) { cases := map[string]string{ "my-plugin": `my-plugin`, "# Plugin: foo /": `# Plugin: foo \/`, "# Plugin: a.b /": `# Plugin: a\.b \/`, "foo[bar]": `foo\[bar\]`, "a*b+c?": `a\*b\+c\?`, "path|with|pipes": `path\|with\|pipes`, `back\slash`: `back\\slash`, "": ``, } for in, want := range cases { got := regexpEscapeForAwk(in) if got != want { t.Errorf("regexpEscapeForAwk(%q) = %q, want %q", in, got, want) } } } // ---------- stripPluginMarkersFromMemory: awk script logic ---------- // // We can't unit-test the in-container exec without Docker, but we CAN // test the awk script against a real bash on the test runner — which is // exactly the same script that runs in the container. This catches // off-by-one block-stripping bugs without needing a workspace. func TestStripPluginMarkers_AwkScript(t *testing.T) { tmp := t.TempDir() memory := filepath.Join(tmp, "CLAUDE.md") // Mirrors the layout AgentskillsAdaptor.append_to_memory writes: // blank line, marker line, body line(s), blank line separator. initial := `# Agent Workspace Original user content here. # Plugin: my-plugin / rule: foo.md These are my-plugin's rules. Multiple lines of content. # Plugin: keep-me / rule: bar.md Should remain untouched. # Plugin: my-plugin / fragment: baz.md Another my-plugin block. Trailing user content. ` if err := os.WriteFile(memory, []byte(initial), 0o644); err != nil { t.Fatalf("setup: %v", err) } // Run the same awk pipeline the production code uses. marker := "# Plugin: my-plugin /" script := fmt.Sprintf( `awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' %s > %s.new && mv %s.new %s`, regexpEscapeForAwk(marker), memory, memory, memory, memory, ) cmd := exec.Command("bash", "-c", script) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("awk run failed: %v\n%s", err, out) } got, _ := os.ReadFile(memory) gs := string(got) // The two my-plugin blocks must be gone, including their content. if strings.Contains(gs, "my-plugin") { t.Errorf("expected all my-plugin references stripped; got:\n%s", gs) } if strings.Contains(gs, "These are my-plugin's rules.") { t.Errorf("plugin body content leaked through; got:\n%s", gs) } if strings.Contains(gs, "Another my-plugin block.") { t.Errorf("second plugin block content leaked; got:\n%s", gs) } // The keep-me block and surrounding user content must survive intact. for _, want := range []string{ "# Agent Workspace", "Original user content here.", "# Plugin: keep-me / rule: bar.md", "Should remain untouched.", "Trailing user content.", } { if !strings.Contains(gs, want) { t.Errorf("expected %q to remain in CLAUDE.md; got:\n%s", want, gs) } } } // ---------- stripPluginMarkers: missing CLAUDE.md is silent ---------- func TestStripPluginMarkers_MissingFileIsNoOp(t *testing.T) { // Awk on a missing file is a non-zero exit (which our production code // silently ignores via `_, _ =`). Verify that the local invocation // behaves the same and doesn't crash the test process. tmp := t.TempDir() missing := filepath.Join(tmp, "does-not-exist.md") script := fmt.Sprintf( `awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' %s > /tmp/x.$$ 2>/dev/null && mv /tmp/x.$$ %s`, regexpEscapeForAwk("# Plugin: x /"), missing, missing, ) cmd := exec.Command("bash", "-c", script) _ = cmd.Run() // expected to fail; we just check it doesn't hang/panic } // ================== Phase 30.3 — Download endpoint ================== // Download is exercised via an integration-style test because its main // work (stream tar.gz) is straightforward; the interesting paths are // the auth gate and the plugin-name mismatch guard. func TestPluginDownload_RejectsInvalidName(t *testing.T) { h := NewPluginsHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "ws-123"}, {Key: "name", Value: "../traversal"}, } c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/plugins/..%2Ftraversal/download", nil) h.Download(c) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for traversal, got %d", w.Code) } } func TestPluginDownload_MissingTokenWhenWorkspaceHasOne(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) h := NewPluginsHandler(t.TempDir(), nil, nil) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WithArgs("550e8400-e29b-41d4-a716-446655440000"). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}, {Key: "name", Value: "some-plugin"}, } c.Request = httptest.NewRequest("GET", "/workspaces/X/plugins/some-plugin/download", nil) h.Download(c) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 when token required and absent, got %d", w.Code) } } func TestPluginDownload_LegacyWorkspaceStreamsTarball(t *testing.T) { // Stage a small plugin dir; legacy workspace (no tokens on file) is // grandfathered through; caller should get a tar.gz body back. mock := setupTestDB(t) setupTestRedis(t) pluginsDir := t.TempDir() pluginRoot := filepath.Join(pluginsDir, "hello-plugin") if err := os.MkdirAll(pluginRoot, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(pluginRoot, "plugin.yaml"), []byte("name: hello-plugin\nversion: 1.0.0\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(pluginRoot, "rules.md"), []byte("some rules\n"), 0o644); err != nil { t.Fatal(err) } h := NewPluginsHandler(pluginsDir, nil, nil) // Legacy path — workspace has no live tokens. mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WithArgs("550e8400-e29b-41d4-a716-446655440000"). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}, {Key: "name", Value: "hello-plugin"}, } c.Request = httptest.NewRequest("GET", "/workspaces/X/plugins/hello-plugin/download", nil) h.Download(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct != "application/gzip" { t.Errorf("Content-Type: got %q, want application/gzip", ct) } if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="hello-plugin.tar.gz"`) { t.Errorf("Content-Disposition missing canonical filename: %q", cd) } // Body should be gzip-magic-bytes prefixed body := w.Body.Bytes() if len(body) < 2 || body[0] != 0x1f || body[1] != 0x8b { t.Errorf("body does not start with gzip magic (0x1f 0x8b); first bytes: %x", body[:min(4, len(body))]) } } // ================== streamDirAsTar helper ================== func TestStreamDirAsTar_RelativePaths(t *testing.T) { root := t.TempDir() os.MkdirAll(filepath.Join(root, "sub"), 0o755) os.WriteFile(filepath.Join(root, "top.md"), []byte("top content"), 0o644) os.WriteFile(filepath.Join(root, "sub", "nested.md"), []byte("nested content"), 0o644) var buf bytes.Buffer tw := tar.NewWriter(&buf) if err := streamDirAsTar(root, tw); err != nil { t.Fatalf("streamDirAsTar: %v", err) } tw.Close() tr := tar.NewReader(&buf) seen := map[string]bool{} for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatalf("tar read: %v", err) } seen[hdr.Name] = true if strings.HasPrefix(hdr.Name, "/") || strings.Contains(hdr.Name, "..") { t.Errorf("tar entry has unsafe path: %q", hdr.Name) } } // Must include both our files with relative paths if !seen["top.md"] { t.Errorf("missing top.md; saw: %v", seen) } if !seen[filepath.Join("sub", "nested.md")] { t.Errorf("missing sub/nested.md; saw: %v", seen) } } func TestStreamDirAsTar_SkipsSymlinks(t *testing.T) { root := t.TempDir() target := filepath.Join(t.TempDir(), "outside.txt") os.WriteFile(target, []byte("secret"), 0o600) os.WriteFile(filepath.Join(root, "real.md"), []byte("real"), 0o644) if err := os.Symlink(target, filepath.Join(root, "escape.lnk")); err != nil { t.Skipf("symlink unsupported on this fs: %v", err) } var buf bytes.Buffer tw := tar.NewWriter(&buf) streamDirAsTar(root, tw) tw.Close() tr := tar.NewReader(&buf) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } if hdr.Name == "escape.lnk" { t.Error("symlink leaked into archive — would escape staged dir") } } } func min(a, b int) int { if a < b { return a } return b } // Phase 30.3 regression: previously only the local:// path had a unit // test. The github:// (and any other registered scheme) path was only // covered by live E2E. This test mocks the resolver registry so the // non-local install path stays under test even without network access. func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) h := NewPluginsHandler(t.TempDir(), nil, nil) // Reuse the existing test-double — supply a fetchFn that drops a // known file set into dst and returns the plugin name. h.WithSourceResolver(&fakeResolver{ scheme: "github", fetchFn: func(_ context.Context, _ string, dst string) (string, error) { files := map[string]string{ "plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n", "skills/x/SKILL.md": "---\nname: x\n---\n", "adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n", } for relPath, content := range files { full := filepath.Join(dst, relPath) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return "", err } if err := os.WriteFile(full, []byte(content), 0o644); err != nil { return "", err } } return "remote-plugin", nil }, }) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WithArgs("550e8400-e29b-41d4-a716-446655440000"). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}, {Key: "name", Value: "remote-plugin"}, } req := httptest.NewRequest("GET", "/workspaces/X/plugins/remote-plugin/download?source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0", nil) req.URL.RawQuery = "source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0" c.Request = req h.Download(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := w.Header().Get("X-Plugin-Source"); got != "github://acme/remote-plugin#v1.0.0" { t.Errorf("X-Plugin-Source: got %q, want github://acme/remote-plugin#v1.0.0", got) } // Decode + verify the tarball contains the resolver's files gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) if err != nil { t.Fatalf("gzip reader: %v", err) } tr := tar.NewReader(gz) seen := map[string]bool{} for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatalf("tar read: %v", err) } seen[hdr.Name] = true } for _, want := range []string{"plugin.yaml", filepath.Join("skills", "x", "SKILL.md"), filepath.Join("adapters", "claude_code.py")} { if !seen[want] { t.Errorf("expected tar entry %q, saw: %v", want, seen) } } } // Buffered-vs-streamed contract: a tar pack failure must surface as a // clean 5xx with a JSON error, not a truncated 200. We exercise this by // pointing the resolver at a path the OS will refuse to read. func TestPluginDownload_TarPackFailureReturns5xxNotTruncated200(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) pluginsDir := t.TempDir() pluginRoot := filepath.Join(pluginsDir, "broken-plugin") os.MkdirAll(pluginRoot, 0o755) os.WriteFile(filepath.Join(pluginRoot, "plugin.yaml"), []byte("name: broken-plugin\n"), 0o644) // Make a directory we can't read by chmod 000 — streamDirAsTar's // filepath.Walk will surface a permission error. unread := filepath.Join(pluginRoot, "unread") os.MkdirAll(unread, 0o755) os.WriteFile(filepath.Join(unread, "secret"), []byte("x"), 0o600) if err := os.Chmod(unread, 0o000); err != nil { t.Skipf("chmod 0000 unsupported on this fs: %v", err) } t.Cleanup(func() { _ = os.Chmod(unread, 0o755) }) // restore for tempdir cleanup h := NewPluginsHandler(pluginsDir, nil, nil) mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`). WithArgs("550e8400-e29b-41d4-a716-446655440000"). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{ {Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}, {Key: "name", Value: "broken-plugin"}, } c.Request = httptest.NewRequest("GET", "/workspaces/X/plugins/broken-plugin/download", nil) h.Download(c) // On macOS root may bypass chmod; in that case we get a clean 200 + tar // and the test is moot. if w.Code == http.StatusOK && w.Header().Get("Content-Type") == "application/gzip" { t.Skip("running as root; chmod 0000 didn't take effect, skipping") } // The contract this test guards: failure surfaces as a CLEAN 5xx // + JSON body, NEVER as truncated 200 + Content-Type=application/gzip. // The failure can come from either resolveAndStage (502 if the local // resolver can't read the dir) or the tar-pack stage (500 if read // succeeds but tar.Walk hits the unreadable subdir). if w.Code < 500 || w.Code > 599 { t.Fatalf("expected 5xx, got %d: %s", w.Code, w.Body.String()) } if ct := w.Header().Get("Content-Type"); ct == "application/gzip" { t.Errorf("must not advertise gzip on failure path; got Content-Type=%q", ct) } if !strings.HasPrefix(strings.TrimSpace(w.Body.String()), "{") { t.Errorf("expected JSON error body, got: %s", w.Body.String()) } }