package skills import ( "archive/zip" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/utils" ) func newTestRegistry(serverURL, authToken string) *ClawHubRegistry { return NewClawHubRegistry(ClawHubConfig{ Enabled: true, BaseURL: serverURL, AuthToken: authToken, }) } func TestClawHubRegistrySearch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/search", r.URL.Path) assert.Equal(t, "github", r.URL.Query().Get("q")) slug := "github" name := "GitHub Integration" summary := "Interact with GitHub repos" version := "1.0.0" json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "github", 5) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "github", results[0].Slug) assert.Equal(t, "GitHub Integration", results[0].DisplayName) assert.InDelta(t, 0.95, results[0].Score, 0.001) assert.Equal(t, "clawhub", results[0].RegistryName) } func TestClawHubRegistrySearchRetries429(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("rate limited")) return } slug := "github" name := "GitHub Integration" summary := "Interact with GitHub repos" version := "1.0.0" json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ {Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "github", 5) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, 2, attempts) assert.Equal(t, "github", results[0].Slug) } func TestClawHubRegistryGetSkillMeta(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/skills/github", r.URL.Path) json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "github", DisplayName: "GitHub Integration", Summary: "Full GitHub API integration", LatestVersion: &clawhubVersionInfo{ Version: "2.1.0", }, Moderation: &clawhubModerationInfo{ IsMalwareBlocked: false, IsSuspicious: true, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") meta, err := reg.GetSkillMeta(context.Background(), "github") require.NoError(t, err) assert.Equal(t, "github", meta.Slug) assert.Equal(t, "2.1.0", meta.LatestVersion) assert.False(t, meta.IsMalwareBlocked) assert.True(t, meta.IsSuspicious) } func TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) { reg := newTestRegistry("https://example.com", "") _, err := reg.GetSkillMeta(context.Background(), "../etc/passwd") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid slug") } func TestClawHubRegistryDownloadAndInstall(t *testing.T) { // Create a valid ZIP in memory. zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "---\nname: test-skill\ndescription: A test\n---\nHello skill", "README.md": "# Test Skill\n", }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/test-skill": // Metadata endpoint. json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "test-skill", DisplayName: "Test Skill", Summary: "A test skill", LatestVersion: &clawhubVersionInfo{Version: "1.0.0"}, }) case "/api/v1/download": assert.Equal(t, "test-skill", r.URL.Query().Get("slug")) w.Header().Set("Content-Type", "application/zip") w.Write(zipBuf) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "test-skill") reg := newTestRegistry(srv.URL, "") result, err := reg.DownloadAndInstall(context.Background(), "test-skill", "1.0.0", targetDir) require.NoError(t, err) assert.Equal(t, "1.0.0", result.Version) assert.False(t, result.IsMalwareBlocked) // Verify extracted files. skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(skillContent), "Hello skill") readmeContent, err := os.ReadFile(filepath.Join(targetDir, "README.md")) require.NoError(t, err) assert.Contains(t, string(readmeContent), "# Test Skill") } func TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) { zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "---\nname: retry-skill\ndescription: A test\n---\nHello skill", }) downloadAttempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/retry-skill": json.NewEncoder(w).Encode(clawhubSkillResponse{ Slug: "retry-skill", DisplayName: "Retry Skill", Summary: "A retry test skill", LatestVersion: &clawhubVersionInfo{Version: "1.0.0"}, }) case "/api/v1/download": downloadAttempts++ if downloadAttempts == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("rate limited")) return } assert.Equal(t, "retry-skill", r.URL.Query().Get("slug")) w.Header().Set("Content-Type", "application/zip") w.Write(zipBuf) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "retry-skill") reg := newTestRegistry(srv.URL, "") result, err := reg.DownloadAndInstall(context.Background(), "retry-skill", "", targetDir) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "1.0.0", result.Version) assert.Equal(t, 2, downloadAttempts) skillContent, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(skillContent), "Hello skill") } func TestClawHubRegistryAuthToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") assert.Equal(t, "Bearer test-token-123", authHeader) json.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil}) })) defer srv.Close() reg := newTestRegistry(srv.URL, "test-token-123") _, _ = reg.Search(context.Background(), "test", 5) } func TestExtractZipPathTraversal(t *testing.T) { // Create a ZIP with a path traversal entry. var buf bytes.Buffer zw := zip.NewWriter(&buf) // Malicious entry trying to escape directory. w, err := zw.Create("../../etc/passwd") require.NoError(t, err) w.Write([]byte("malicious")) zw.Close() // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "bad.zip") require.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644)) tmpDir := t.TempDir() err = utils.ExtractZipFile(tmpZip, tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "unsafe path") } func TestExtractZipWithSubdirectories(t *testing.T) { zipBuf := createTestZip(t, map[string]string{ "SKILL.md": "root file", "scripts/helper.sh": "#!/bin/bash\necho hello", "examples/demo.yaml": "key: value", }) // Write to temp file for extractZipFile. tmpZip := filepath.Join(t.TempDir(), "test.zip") require.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644)) tmpDir := t.TempDir() targetDir := filepath.Join(tmpDir, "my-skill") err := utils.ExtractZipFile(tmpZip, targetDir) require.NoError(t, err) // Verify nested file. data, err := os.ReadFile(filepath.Join(targetDir, "scripts", "helper.sh")) require.NoError(t, err) assert.Contains(t, string(data), "#!/bin/bash") } func TestClawHubRegistrySearchHTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal Server Error")) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") _, err := reg.Search(context.Background(), "test", 5) assert.Error(t, err) assert.Contains(t, err.Error(), "500") } func TestClawHubRegistrySearchNullableFields(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { validSlug := "valid-slug" validSummary := "valid summary" // Return results with various null/empty fields json.NewEncoder(w).Encode(clawhubSearchResponse{ Results: []clawhubSearchResult{ // Case 1: Null Slug -> Skip {Score: 0.1, Slug: nil, DisplayName: nil, Summary: nil, Version: nil}, // Case 2: Valid Slug, Null Summary -> Skip {Score: 0.2, Slug: &validSlug, DisplayName: nil, Summary: nil, Version: nil}, // Case 3: Valid Slug, Valid Summary, Null Name -> Keep, Name=Slug {Score: 0.8, Slug: &validSlug, DisplayName: nil, Summary: &validSummary, Version: nil}, }, }) })) defer srv.Close() reg := newTestRegistry(srv.URL, "") results, err := reg.Search(context.Background(), "test", 5) require.NoError(t, err) require.Len(t, results, 1, "should only return 1 valid result") r := results[0] assert.Equal(t, "valid-slug", r.Slug) assert.Equal(t, "valid-slug", r.DisplayName, "should fallback name to slug") assert.Equal(t, "valid summary", r.Summary) } // --- helpers --- func createTestZip(t *testing.T, files map[string]string) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, content := range files { w, err := zw.Create(name) require.NoError(t, err) _, err = w.Write([]byte(content)) require.NoError(t, err) } require.NoError(t, zw.Close()) return buf.Bytes() }