diff --git a/platform/internal/handlers/plugins_install_pipeline_test.go b/platform/internal/handlers/plugins_install_pipeline_test.go new file mode 100644 index 00000000..05eadf6a --- /dev/null +++ b/platform/internal/handlers/plugins_install_pipeline_test.go @@ -0,0 +1,574 @@ +package handlers + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins" + "github.com/gin-gonic/gin" +) + +// ==================== validatePluginName ==================== + +func TestValidatePluginName_Valid(t *testing.T) { + valid := []string{ + "my-plugin", + "plugin_name", + "MyPlugin", + "123abc", + "a", + "superpowers", + "molecule-dev", + } + for _, name := range valid { + t.Run(name, func(t *testing.T) { + if err := validatePluginName(name); err != nil { + t.Errorf("validatePluginName(%q) returned unexpected error: %v", name, err) + } + }) + } +} + +func TestValidatePluginName_Empty(t *testing.T) { + if err := validatePluginName(""); err == nil { + t.Error("expected error for empty plugin name") + } +} + +func TestValidatePluginName_ForwardSlash(t *testing.T) { + if err := validatePluginName("foo/bar"); err == nil { + t.Error("expected error for plugin name containing '/'") + } +} + +func TestValidatePluginName_Backslash(t *testing.T) { + if err := validatePluginName(`foo\bar`); err == nil { + t.Error("expected error for plugin name containing '\\'") + } +} + +func TestValidatePluginName_DotDot(t *testing.T) { + if err := validatePluginName(".."); err == nil { + t.Error("expected error for '..'") + } +} + +func TestValidatePluginName_DotDotEmbedded(t *testing.T) { + if err := validatePluginName("foo..bar"); err == nil { + t.Error("expected error for name containing '..'") + } +} + +func TestValidatePluginName_PathTraversalCases(t *testing.T) { + cases := []string{ + "../etc", + "foo/../bar", + "../../secrets", + } + for _, name := range cases { + t.Run(name, func(t *testing.T) { + if err := validatePluginName(name); err == nil { + t.Errorf("validatePluginName(%q): expected error for path traversal", name) + } + }) + } +} + +// ==================== dirSize ==================== + +func TestDirSize_EmptyDir(t *testing.T) { + dir := t.TempDir() + size, err := dirSize(dir, 1000) + if err != nil { + t.Fatalf("unexpected error on empty dir: %v", err) + } + if size != 0 { + t.Errorf("expected size 0 for empty dir, got %d", size) + } +} + +func TestDirSize_SingleFile(t *testing.T) { + dir := t.TempDir() + content := []byte("hello world") // 11 bytes + if err := os.WriteFile(filepath.Join(dir, "file.txt"), content, 0600); err != nil { + t.Fatal(err) + } + size, err := dirSize(dir, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if size != int64(len(content)) { + t.Errorf("expected size %d, got %d", len(content), size) + } +} + +func TestDirSize_MultipleFiles(t *testing.T) { + dir := t.TempDir() + files := map[string][]byte{ + "a.txt": []byte("hello"), // 5 + "b.txt": []byte("world!"), // 6 + } + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0600); err != nil { + t.Fatal(err) + } + } + size, err := dirSize(dir, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if size != 11 { + t.Errorf("expected size 11, got %d", size) + } +} + +func TestDirSize_Subdirectories(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "subdir") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("nested"), 0600); err != nil { + t.Fatal(err) + } + size, err := dirSize(dir, 1000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if size != 6 { + t.Errorf("expected size 6, got %d", size) + } +} + +func TestDirSize_ExceedsLimit(t *testing.T) { + dir := t.TempDir() + // Write a 100-byte file, set limit to 50. + if err := os.WriteFile(filepath.Join(dir, "big.bin"), make([]byte, 100), 0600); err != nil { + t.Fatal(err) + } + _, err := dirSize(dir, 50) + if err == nil { + t.Error("expected error when dir size exceeds limit") + } + if !strings.Contains(err.Error(), "cap") { + t.Errorf("expected error to mention cap, got: %v", err) + } +} + +func TestDirSize_ExactlyAtLimit(t *testing.T) { + dir := t.TempDir() + // A 10-byte file with limit=10 should succeed (not exceed). + if err := os.WriteFile(filepath.Join(dir, "exact.bin"), make([]byte, 10), 0600); err != nil { + t.Fatal(err) + } + size, err := dirSize(dir, 10) + if err != nil { + t.Errorf("exactly at limit should not error, got: %v", err) + } + if size != 10 { + t.Errorf("expected size 10, got %d", size) + } +} + +// ==================== httpErr / newHTTPErr ==================== + +func TestHTTPErr_Error_ContainsStatus(t *testing.T) { + e := newHTTPErr(http.StatusBadRequest, gin.H{"error": "bad input"}) + msg := e.Error() + if !strings.Contains(msg, "400") { + t.Errorf("Error() should contain status code 400, got: %q", msg) + } +} + +func TestHTTPErr_StatusPreserved(t *testing.T) { + cases := []int{ + http.StatusBadRequest, + http.StatusNotFound, + http.StatusBadGateway, + http.StatusGatewayTimeout, + http.StatusRequestEntityTooLarge, + http.StatusInternalServerError, + } + for _, code := range cases { + e := newHTTPErr(code, gin.H{"error": "test"}) + if e.Status != code { + t.Errorf("newHTTPErr(%d): Status = %d, want %d", code, e.Status, code) + } + } +} + +func TestHTTPErr_ErrorsAs_Unwraps(t *testing.T) { + original := newHTTPErr(http.StatusBadGateway, gin.H{"error": "upstream"}) + wrapped := fmt.Errorf("outer: %w", original) + var he *httpErr + if !errors.As(wrapped, &he) { + t.Fatal("errors.As should unwrap *httpErr through fmt.Errorf %w") + } + if he.Status != http.StatusBadGateway { + t.Errorf("expected 502, got %d", he.Status) + } +} + +// ==================== regexpEscapeForAwk ==================== + +func TestRegexpEscapeForAwk_PlainName(t *testing.T) { + // Alphanumeric + hyphen + underscore should be returned unchanged. + input := "my-plugin_123" + got := regexpEscapeForAwk(input) + if got != input { + t.Errorf("regexpEscapeForAwk(%q) = %q, want unchanged", input, got) + } +} + +func TestRegexpEscapeForAwk_Slash(t *testing.T) { + // Slash is the awk regex delimiter and MUST be escaped. + got := regexpEscapeForAwk("a/b") + if got != `a\/b` { + t.Errorf("regexpEscapeForAwk(%q) = %q, want %q", "a/b", got, `a\/b`) + } +} + +func TestRegexpEscapeForAwk_Dot(t *testing.T) { + got := regexpEscapeForAwk("a.b") + if got != `a\.b` { + t.Errorf("regexpEscapeForAwk(%q) = %q, want %q", "a.b", got, `a\.b`) + } +} + +func TestRegexpEscapeForAwk_Plus(t *testing.T) { + got := regexpEscapeForAwk("a+b") + if got != `a\+b` { + t.Errorf("regexpEscapeForAwk(%q) = %q, want %q", "a+b", got, `a\+b`) + } +} + +func TestRegexpEscapeForAwk_FullMarkerString(t *testing.T) { + // The actual marker used in stripPluginMarkersFromMemory. + // "# Plugin: my-plugin /" must have "/" escaped but " " unescaped. + marker := "# Plugin: my-plugin /" + got := regexpEscapeForAwk(marker) + if !strings.Contains(got, `\/`) { + t.Errorf("expected escaped slash in output for %q, got %q", marker, got) + } + if strings.Contains(got, `\ `) { + t.Errorf("space should NOT be escaped in %q", marker) + } +} + +func TestRegexpEscapeForAwk_NoDoubleEscape(t *testing.T) { + // A backslash in the input should itself be escaped. + got := regexpEscapeForAwk(`a\b`) + if !strings.HasPrefix(got, `a\\`) { + t.Errorf("backslash should be escaped, got %q", got) + } +} + +// ==================== streamDirAsTar ==================== + +func TestStreamDirAsTar_EmptyDir(t *testing.T) { + root := t.TempDir() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := streamDirAsTar(root, tw); err != nil { + t.Fatalf("unexpected error on empty dir: %v", err) + } + tw.Close() + + tr := tar.NewReader(&buf) + count := 0 + for { + if _, err := tr.Next(); err != nil { + break + } + count++ + } + if count != 0 { + t.Errorf("expected 0 tar entries for empty dir, got %d", count) + } +} + +func TestStreamDirAsTar_SingleFile(t *testing.T) { + root := t.TempDir() + content := []byte("plugin manifest content") + if err := os.WriteFile(filepath.Join(root, "plugin.yaml"), content, 0600); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := streamDirAsTar(root, tw); err != nil { + t.Fatalf("streamDirAsTar failed: %v", err) + } + tw.Close() + + entries := tarEntries(t, &buf) + if _, ok := entries["plugin.yaml"]; !ok { + t.Error("tar should contain plugin.yaml") + } + if string(entries["plugin.yaml"]) != string(content) { + t.Errorf("plugin.yaml content mismatch: got %q", entries["plugin.yaml"]) + } +} + +func TestStreamDirAsTar_NestedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "rules"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "rules", "main.md"), []byte("# Rule"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "plugin.yaml"), []byte("name: test\n"), 0600); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := streamDirAsTar(root, tw); err != nil { + t.Fatalf("streamDirAsTar failed: %v", err) + } + tw.Close() + + entries := tarEntries(t, &buf) + if _, ok := entries["plugin.yaml"]; !ok { + t.Error("tar should contain plugin.yaml") + } + // Nested paths must use forward slashes regardless of OS. + if _, ok := entries["rules/main.md"]; !ok { + t.Errorf("tar should contain rules/main.md; got entries: %v", entryKeys(entries)) + } +} + +func TestStreamDirAsTar_PathsAreRelative(t *testing.T) { + // Entries must be relative paths — no leading slash, no tempdir prefix. + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := streamDirAsTar(root, tw); err != nil { + t.Fatalf("streamDirAsTar failed: %v", err) + } + tw.Close() + + tr := tar.NewReader(&buf) + for { + hdr, err := tr.Next() + if err != nil { + break + } + if strings.HasPrefix(hdr.Name, "/") { + t.Errorf("tar entry %q has absolute path", hdr.Name) + } + if strings.Contains(hdr.Name, "tmp") || strings.Contains(hdr.Name, "var") { + t.Errorf("tar entry %q leaks tempdir path", hdr.Name) + } + } +} + +// ==================== resolveAndStage (with stub resolver) ==================== + +// stubResolver is a minimal SourceResolver for testing resolveAndStage +// without requiring a real Docker client or live plugin registry. +type stubResolver struct { + scheme string + name string // plugin name returned from Fetch + content string // file content written into dst + fetchErr error // non-nil causes Fetch to return this error +} + +func (s *stubResolver) Scheme() string { return s.scheme } +func (s *stubResolver) Fetch(_ context.Context, _ string, dst string) (string, error) { + if s.fetchErr != nil { + return "", s.fetchErr + } + // Write a minimal file so dirSize has something to measure. + if err := os.WriteFile(filepath.Join(dst, "plugin.yaml"), []byte(s.content), 0600); err != nil { + return "", err + } + return s.name, nil +} + +func TestResolveAndStage_EmptySource(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: ""}) + assertHTTPErrStatus(t, err, http.StatusBadRequest, "empty source") +} + +func TestResolveAndStage_UnknownScheme(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "nosuchthing://plugin"}) + assertHTTPErrStatus(t, err, http.StatusBadRequest, "unknown scheme") +} + +func TestResolveAndStage_HappyPath(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + name: "my-plugin", + content: "name: my-plugin\nversion: 1.0.0\n", + }) + + result, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://my-plugin"}) + if err != nil { + t.Fatalf("unexpected error on happy path: %v", err) + } + defer os.RemoveAll(result.StagedDir) + + if result.PluginName != "my-plugin" { + t.Errorf("expected PluginName 'my-plugin', got %q", result.PluginName) + } + if result.Source.Scheme != "stub" { + t.Errorf("expected Source.Scheme 'stub', got %q", result.Source.Scheme) + } + // The staged directory must exist and contain the file. + if _, err := os.Stat(filepath.Join(result.StagedDir, "plugin.yaml")); os.IsNotExist(err) { + t.Error("staged dir should contain plugin.yaml after successful fetch") + } +} + +func TestResolveAndStage_StagedDirCleanedOnFetchError(t *testing.T) { + // resolveAndStage must remove the staging tempdir if Fetch fails. + // We verify this by capturing the stagedDir path from the error path; + // since we can't inspect it directly, we verify that no extra tempdirs + // are left behind after the function returns. + beforeCount := tempDirCount(t) + + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + fetchErr: errors.New("simulated fetch failure"), + }) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://plugin"}) + if err == nil { + t.Fatal("expected error from fetch failure") + } + + afterCount := tempDirCount(t) + if afterCount > beforeCount { + t.Errorf("resolveAndStage left %d orphaned tempdir(s) after fetch error", afterCount-beforeCount) + } +} + +func TestResolveAndStage_FetchReturnsErrPluginNotFound(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + fetchErr: plugins.ErrPluginNotFound, + }) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://missing"}) + assertHTTPErrStatus(t, err, http.StatusNotFound, "ErrPluginNotFound") +} + +func TestResolveAndStage_FetchReturnsDeadlineExceeded(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + fetchErr: context.DeadlineExceeded, + }) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://slow"}) + assertHTTPErrStatus(t, err, http.StatusGatewayTimeout, "DeadlineExceeded") +} + +func TestResolveAndStage_FetchReturnsGenericError(t *testing.T) { + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + fetchErr: errors.New("connection refused"), + }) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://anything"}) + assertHTTPErrStatus(t, err, http.StatusBadGateway, "generic fetch error") +} + +func TestResolveAndStage_ResolverReturnsInvalidName(t *testing.T) { + // A resolver returning a name with path traversal must yield 400. + h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{ + scheme: "stub", + name: "foo/bar", // invalid: contains slash + content: "name: test\n", + }) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "stub://anything"}) + assertHTTPErrStatus(t, err, http.StatusBadRequest, "invalid name from resolver") +} + +func TestResolveAndStage_LocalSchemePathTraversal(t *testing.T) { + // "local://../../etc/passwd" must be rejected before Fetch is called, + // preventing path-traversal on the platform's plugin registry directory. + h := NewPluginsHandler(t.TempDir(), nil, nil) + _, err := h.resolveAndStage(context.Background(), installRequest{Source: "local://../../etc/passwd"}) + assertHTTPErrStatus(t, err, http.StatusBadRequest, "local path traversal") +} + +// ==================== helpers ==================== + +// assertHTTPErrStatus is a test helper that checks err is a *httpErr with +// the expected status code. Fails with a clear message if either condition +// is not met. +func assertHTTPErrStatus(t *testing.T, err error, want int, label string) { + t.Helper() + if err == nil { + t.Fatalf("[%s] expected *httpErr with status %d, got nil error", label, want) + } + var he *httpErr + if !errors.As(err, &he) { + t.Fatalf("[%s] expected *httpErr, got %T: %v", label, err, err) + } + if he.Status != want { + t.Errorf("[%s] expected status %d, got %d", label, want, he.Status) + } +} + +// tarEntries reads all entries from a tar.Reader backed by buf and returns +// a map of entry name → string content. The caller must have already +// written and closed the tar.Writer before calling this. +func tarEntries(t *testing.T, buf *bytes.Buffer) map[string]string { + t.Helper() + tr := tar.NewReader(bytes.NewReader(buf.Bytes())) + entries := make(map[string]string) + for { + hdr, err := tr.Next() + if err != nil { + break + } + var b bytes.Buffer + if _, err := io.Copy(&b, tr); err != nil { + t.Fatalf("failed to read tar entry %s: %v", hdr.Name, err) + } + // Normalize OS path separators so tests pass on Windows too. + entries[filepath.ToSlash(hdr.Name)] = b.String() + } + return entries +} + +// entryKeys returns the sorted list of keys from a map[string]string for +// inclusion in error messages. +func entryKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// tempDirCount counts the number of entries in os.TempDir() that look like +// molecule-plugin-fetch-* staging dirs. Used to verify cleanup on error. +func tempDirCount(t *testing.T) int { + t.Helper() + entries, err := os.ReadDir(os.TempDir()) + if err != nil { + t.Fatalf("failed to read tempdir: %v", err) + } + count := 0 + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), "molecule-plugin-fetch-") { + count++ + } + } + return count +}