package plugins import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestGithubResolver_Scheme(t *testing.T) { if NewGithubResolver().Scheme() != "github" { t.Error("scheme must be 'github'") } } // Stub git runner that writes a synthetic repo tree into the clone // target dir, so tests don't need a real git binary or network. func stubGit(repoContents map[string]string) func(ctx context.Context, dir string, args ...string) error { return func(ctx context.Context, dir string, args ...string) error { if err := ctx.Err(); err != nil { return err } if len(args) < 2 || args[0] != "clone" { return errors.New("unexpected git args") } target := args[len(args)-1] if err := os.MkdirAll(target, 0o755); err != nil { return err } // Synthesize a .git dir so we can prove the resolver strips it. if err := os.MkdirAll(filepath.Join(target, ".git"), 0o755); err != nil { return err } if err := os.WriteFile(filepath.Join(target, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0o644); err != nil { return err } for path, content := range repoContents { full := filepath.Join(target, path) 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 nil } } func TestGithubResolver_ClonesAndStripsGitDir(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") r := &GithubResolver{ GitRunner: stubGit(map[string]string{ "plugin.yaml": "name: demo\n", "skills/h/SKILL.md": "---\nname: h\ndescription: d\n---\n", "adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n", }), BaseURL: "file:///dev/null", } dst := t.TempDir() name, err := r.Fetch(context.Background(), "org/repo", dst) if err != nil { t.Fatalf("unexpected err: %v", err) } if name != "repo" { t.Errorf("got name %q, want 'repo'", name) } // Contents copied. for _, want := range []string{"plugin.yaml", "skills/h/SKILL.md", "adapters/claude_code.py"} { if _, err := os.Stat(filepath.Join(dst, want)); err != nil { t.Errorf("missing %q: %v", want, err) } } // .git was stripped from the clone target before copy, so dst has no .git. if _, err := os.Stat(filepath.Join(dst, ".git")); !os.IsNotExist(err) { t.Error(".git dir must not survive into dst") } } func TestGithubResolver_PassesRefAsBranch(t *testing.T) { var seenArgs []string r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { seenArgs = args target := args[len(args)-1] _ = os.MkdirAll(target, 0o755) return nil }, } if _, err := r.Fetch(context.Background(), "org/repo#v1.2.0", t.TempDir()); err != nil { t.Fatalf("unexpected err: %v", err) } if !containsArg(seenArgs, "--branch") || !containsArg(seenArgs, "v1.2.0") { t.Errorf("args should include --branch v1.2.0, got %v", seenArgs) } } func TestGithubResolver_OmitsBranchFlagWhenNoRef(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") var seenArgs []string r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { seenArgs = args target := args[len(args)-1] _ = os.MkdirAll(target, 0o755) return nil }, } if _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()); err != nil { t.Fatalf("unexpected err: %v", err) } if containsArg(seenArgs, "--branch") { t.Errorf("no ref → no --branch flag, got %v", seenArgs) } } func TestGithubResolver_RejectsInvalidSpec(t *testing.T) { r := NewGithubResolver() for _, spec := range []string{ "", "single-segment", "too/many/segments", "/leading-slash", "trailing/", "bad char/repo", "org/repo#bad ref", } { t.Run(spec, func(t *testing.T) { _, err := r.Fetch(context.Background(), spec, t.TempDir()) if err == nil { t.Errorf("should have rejected %q", spec) } }) } } func TestGithubResolver_BubblesUpGitError(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { return errors.New("simulated auth failure") }, } _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "simulated") { t.Errorf("error should bubble git failure: %v", err) } } func TestGithubResolver_UsesDefaultsWhenNilFields(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") // A zero-value GithubResolver should still have defaults filled in // at Fetch time. Verified indirectly: we pass a stub that records // the URL passed to `git clone`. var seenArgs []string r := &GithubResolver{} r.GitRunner = func(ctx context.Context, dir string, args ...string) error { seenArgs = args target := args[len(args)-1] return os.MkdirAll(target, 0o755) } if _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()); err != nil { t.Fatalf("unexpected err: %v", err) } url := "" for _, a := range seenArgs { if strings.HasPrefix(a, "http") { url = a break } } if !strings.HasPrefix(url, "https://github.com/org/repo") { t.Errorf("default BaseURL not applied, got %q", url) } } func containsArg(args []string, target string) bool { for _, a := range args { if a == target { return true } } return false } // ---- defaultGitRunner ---- func TestDefaultGitRunner_PropagatesFailureFromMissingGit(t *testing.T) { t.Setenv("PATH", "/nonexistent") err := defaultGitRunner(context.Background(), t.TempDir(), "status") if err == nil { t.Error("expected error when git is unavailable on PATH") } } func TestDefaultGitRunner_UsesWorkingDirHomeFallback(t *testing.T) { // Force HOME empty so the resolver adds the fallback. t.Setenv("HOME", "") // Still need real git or a bogus arg. Use `--version` which succeeds // on any system that has git, then skip if not. if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed on this system") } if err := defaultGitRunner(context.Background(), t.TempDir(), "--version"); err != nil { t.Errorf("git --version should succeed: %v", err) } } func TestGithubResolver_NilGitRunnerUsesDefault(t *testing.T) { // Passing nil GitRunner should fall back to defaultGitRunner. With no // git on PATH, that fallback errors — we don't need real git here. t.Setenv("PATH", "/nonexistent") r := &GithubResolver{GitRunner: nil, BaseURL: "https://example.com"} _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()) if err == nil { t.Error("expected default git runner to error without a git binary") } } func TestGithubResolver_CopyToDstFailure(t *testing.T) { r := &GithubResolver{ GitRunner: stubGit(map[string]string{"plugin.yaml": "name: x\n"}), } // Make dst read-only so copyTree fails after the successful clone. dst := t.TempDir() if err := os.Chmod(dst, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(dst, 0o755) }) _, err := r.Fetch(context.Background(), "org/repo", dst) if err == nil { t.Error("expected copy failure when dst is read-only") } } func TestGithubResolver_AlwaysPassesDepth1(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") var seenArgs []string r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { seenArgs = args target := args[len(args)-1] return os.MkdirAll(target, 0o755) }, } if _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()); err != nil { t.Fatalf("unexpected err: %v", err) } if !containsArg(seenArgs, "--depth=1") { t.Errorf("expected --depth=1 in git args, got %v", seenArgs) } } func TestGithubResolver_PassesDoubleDashBeforeURL(t *testing.T) { // When a ref is specified, we pass `--` after --branch as // defense-in-depth against ref-as-flag injection. var seenArgs []string r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { seenArgs = args target := args[len(args)-1] return os.MkdirAll(target, 0o755) }, } if _, err := r.Fetch(context.Background(), "org/repo#main", t.TempDir()); err != nil { t.Fatalf("unexpected err: %v", err) } if !containsArg(seenArgs, "--") { t.Errorf("expected `--` separator in git args, got %v", seenArgs) } } func TestGithubResolver_RejectsRefStartingWithHyphen(t *testing.T) { r := NewGithubResolver() _, err := r.Fetch(context.Background(), "org/repo#-exec=/evil", t.TempDir()) if err == nil { t.Error("ref starting with '-' must be rejected") } } func TestGithubResolver_MapsRepositoryNotFoundToSentinel(t *testing.T) { t.Setenv("PLUGIN_ALLOW_UNPINNED", "true") r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { return errors.New("remote: Repository not found.\nfatal: repository 'https://github.com/x/y.git' not found") }, } _, err := r.Fetch(context.Background(), "org/repo", t.TempDir()) if !errors.Is(err, ErrPluginNotFound) { t.Errorf("expected ErrPluginNotFound, got %v", err) } } func TestGithubResolver_MapsMissingBranchToSentinel(t *testing.T) { r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { return errors.New("fatal: Remote branch bogus not found in upstream origin") }, } _, err := r.Fetch(context.Background(), "org/repo#bogus", t.TempDir()) if !errors.Is(err, ErrPluginNotFound) { t.Errorf("expected ErrPluginNotFound for missing ref, got %v", err) } } func TestGithubResolver_AuthFailureIsNotErrPluginNotFound(t *testing.T) { r := &GithubResolver{ GitRunner: func(ctx context.Context, dir string, args ...string) error { return errors.New("fatal: Authentication failed for 'https://github.com/private/repo.git/'") }, } _, err := r.Fetch(context.Background(), "private/repo", t.TempDir()) if err == nil { t.Fatal("expected error") } if errors.Is(err, ErrPluginNotFound) { t.Errorf("auth failure must not surface as ErrPluginNotFound: %v", err) } }