molecule-core/platform/internal/plugins/local_test.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

251 lines
7.3 KiB
Go

package plugins
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func writePlugin(t *testing.T, base, name string, files map[string]string) string {
t.Helper()
dir := filepath.Join(base, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for path, content := range files {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestLocalResolver_Scheme(t *testing.T) {
if (&LocalResolver{}).Scheme() != "local" {
t.Error("scheme must be 'local'")
}
}
func TestLocalResolver_CopiesPluginTree(t *testing.T) {
base := t.TempDir()
writePlugin(t, base, "demo", map[string]string{
"plugin.yaml": "name: demo\n",
"rules/one.md": "- rule",
"skills/hello/SKILL.md": "---\nname: hello\ndescription: d\n---\nbody",
"skills/hello/scripts/t.py": "# tool",
})
dst := t.TempDir()
r := NewLocalResolver(base)
name, err := r.Fetch(context.Background(), "demo", dst)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if name != "demo" {
t.Errorf("got name %q", name)
}
// Verify every file made it across.
for _, want := range []string{
"plugin.yaml",
"rules/one.md",
"skills/hello/SKILL.md",
"skills/hello/scripts/t.py",
} {
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
t.Errorf("missing %q in dst: %v", want, err)
}
}
}
func TestLocalResolver_RejectsPathTraversal(t *testing.T) {
r := NewLocalResolver(t.TempDir())
cases := []string{
"",
" ",
"../evil",
"foo/bar",
"..",
"./hidden",
"foo\\bar",
}
for _, name := range cases {
t.Run(name, func(t *testing.T) {
_, err := r.Fetch(context.Background(), name, t.TempDir())
if err == nil {
t.Errorf("Fetch(%q) should have failed", name)
}
})
}
}
func TestLocalResolver_MissingPluginReturnsError(t *testing.T) {
r := NewLocalResolver(t.TempDir())
_, err := r.Fetch(context.Background(), "not-here", t.TempDir())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error should mention 'not found': %v", err)
}
}
func TestLocalResolver_RejectsNonDirectoryTarget(t *testing.T) {
base := t.TempDir()
// "demo" exists but is a file, not a dir.
if err := os.WriteFile(filepath.Join(base, "demo"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
r := NewLocalResolver(base)
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Errorf("error should mention 'not a directory': %v", err)
}
}
func TestLocalResolver_HonoursContextCancellation(t *testing.T) {
// Make a plugin with enough files to take a moment.
base := t.TempDir()
files := map[string]string{}
for i := 0; i < 20; i++ {
files[filepath.Join("data", "f"+string(rune('a'+i))+".txt")] = strings.Repeat("x", 1024)
}
writePlugin(t, base, "big", files)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before starting — walk should abort fast
r := NewLocalResolver(base)
_, err := r.Fetch(ctx, "big", t.TempDir())
if err == nil {
t.Error("expected cancellation error")
}
}
func TestLocalResolver_BubblesUpCopyFailure(t *testing.T) {
// Source file the copyTree walk would read; make dst unwritable so
// the copyFile step fails.
base := t.TempDir()
writePlugin(t, base, "demo", map[string]string{
"plugin.yaml": "name: demo\n",
})
dst := t.TempDir()
// Make dst read-only so creating files inside it fails.
if err := os.Chmod(dst, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dst, 0o755) })
r := NewLocalResolver(base)
_, err := r.Fetch(context.Background(), "demo", dst)
if err == nil {
t.Error("expected copy failure when dst is read-only")
}
}
func TestLocalResolver_CopyFileSourceUnreadable(t *testing.T) {
base := t.TempDir()
pluginDir := writePlugin(t, base, "demo", map[string]string{
"plugin.yaml": "name: demo\n",
})
// Make the source file unreadable — copyFile should error.
if err := os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o644) })
r := NewLocalResolver(base)
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
// Root can read any file, so this test only asserts on non-root hosts.
if os.Getuid() == 0 {
t.Skip("running as root — cannot exercise unreadable-file branch")
}
if err == nil {
t.Error("expected error when source file is unreadable")
}
}
func TestLocalResolver_WalkErrorPropagates(t *testing.T) {
// Put a plugin dir in place, then replace its subdirectory with an
// unreadable one so filepath.Walk surfaces walkErr to our callback.
if os.Getuid() == 0 {
t.Skip("running as root — cannot exercise Walk error branch")
}
base := t.TempDir()
pluginDir := writePlugin(t, base, "demo", map[string]string{
"sub/file.txt": "x",
})
// Make the subdir unreadable so Walk's readdir fails.
if err := os.Chmod(filepath.Join(pluginDir, "sub"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "sub"), 0o755) })
r := NewLocalResolver(base)
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
if err == nil {
t.Error("expected Walk error to propagate")
}
}
func TestLocalResolver_MissingReturnsErrPluginNotFound(t *testing.T) {
r := NewLocalResolver(t.TempDir())
_, err := r.Fetch(context.Background(), "absent", t.TempDir())
if !errors.Is(err, ErrPluginNotFound) {
t.Errorf("expected ErrPluginNotFound, got %v", err)
}
}
func TestLocalResolver_NonNotExistStatErrorIsNotErrPluginNotFound(t *testing.T) {
// stat failing for reasons other than "doesn't exist" should NOT
// surface as ErrPluginNotFound. Hard to reliably trigger cross-
// platform, but we can at least prove the wrapping on a read-only
// permission-denied BaseDir.
if os.Getuid() == 0 {
t.Skip("running as root — cannot exercise permission branch")
}
base := t.TempDir()
locked := base + "/locked"
if err := os.Mkdir(locked, 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
r := NewLocalResolver(locked)
_, err := r.Fetch(context.Background(), "anything", t.TempDir())
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, ErrPluginNotFound) {
t.Errorf("permission-denied stat must not be surfaced as ErrPluginNotFound: %v", err)
}
}
func TestLocalResolver_RejectsOverlongName(t *testing.T) {
// 129-char name (1 + 128 tail); max is 1 + 127 = 128.
long := "a" + strings.Repeat("b", 128)
r := NewLocalResolver(t.TempDir())
_, err := r.Fetch(context.Background(), long, t.TempDir())
if err == nil {
t.Error("overlong name should be rejected")
}
// Exactly at limit should still work (just the regex check; will
// still fail at the stat step since the dir doesn't exist, but with
// ErrPluginNotFound, not a format error).
atLimit := "a" + strings.Repeat("b", 127)
_, err = r.Fetch(context.Background(), atLimit, t.TempDir())
if err == nil {
t.Error("at-limit name with missing dir should still err")
} else if !errors.Is(err, ErrPluginNotFound) {
t.Errorf("at-limit name should err with ErrPluginNotFound (format is fine), got %v", err)
}
}