mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Import a skill from a local .skill/.zip archive: POST /api/skills/import now accepts a multipart upload (file + on_conflict) alongside the JSON URL body, and the CLI gains `multica skill import --file <path>`. Reuses the existing create + on_conflict contract, per-file/bundle/count caps, reserved-SKILL.md rule, and a zip-slip guard. Closes #4730 MUL-3865
313 lines
9.3 KiB
Go
313 lines
9.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildTestZip packs the given path->content map into an in-memory zip and
|
|
// returns its bytes. A path ending in "/" is written as a directory entry.
|
|
func buildTestZip(t *testing.T, entries map[string]string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
for name, content := range entries {
|
|
if strings.HasSuffix(name, "/") {
|
|
if _, err := zw.Create(name); err != nil {
|
|
t.Fatalf("create dir entry %q: %v", name, err)
|
|
}
|
|
continue
|
|
}
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create entry %q: %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write entry %q: %v", name, err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func filePaths(imported *importedSkill) []string {
|
|
paths := make([]string, 0, len(imported.files))
|
|
for _, f := range imported.files {
|
|
paths = append(paths, f.path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func fileContent(imported *importedSkill, path string) (string, bool) {
|
|
for _, f := range imported.files {
|
|
if f.path == path {
|
|
return f.content, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
const testSkillMd = `---
|
|
name: review-helper
|
|
description: Reviews code changes
|
|
---
|
|
|
|
# Review Helper
|
|
|
|
Do the review.
|
|
`
|
|
|
|
func TestParseSkillArchive_NestedWrapper(t *testing.T) {
|
|
data := buildTestZip(t, map[string]string{
|
|
"review-helper/": "",
|
|
"review-helper/SKILL.md": testSkillMd,
|
|
"review-helper/scripts/run.sh": "echo hi",
|
|
"review-helper/references/g.md": "guide",
|
|
})
|
|
|
|
imported, err := parseSkillArchive(data, "review-helper.skill")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
if imported.name != "review-helper" {
|
|
t.Errorf("name = %q, want review-helper", imported.name)
|
|
}
|
|
if imported.description != "Reviews code changes" {
|
|
t.Errorf("description = %q", imported.description)
|
|
}
|
|
if !strings.Contains(imported.content, "# Review Helper") {
|
|
t.Errorf("content missing SKILL.md body: %q", imported.content)
|
|
}
|
|
got := filePaths(imported)
|
|
want := map[string]bool{"scripts/run.sh": true, "references/g.md": true}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("files = %v, want keys %v", got, want)
|
|
}
|
|
for _, p := range got {
|
|
if !want[p] {
|
|
t.Errorf("unexpected file %q (SKILL.md must not be a supporting file)", p)
|
|
}
|
|
}
|
|
if c, ok := fileContent(imported, "scripts/run.sh"); !ok || c != "echo hi" {
|
|
t.Errorf("scripts/run.sh content = %q, ok=%v", c, ok)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_RootLayout(t *testing.T) {
|
|
data := buildTestZip(t, map[string]string{
|
|
"SKILL.md": testSkillMd,
|
|
"references/doc.md": "doc",
|
|
})
|
|
imported, err := parseSkillArchive(data, "anything.zip")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
if imported.name != "review-helper" {
|
|
t.Errorf("name = %q, want review-helper", imported.name)
|
|
}
|
|
if got := filePaths(imported); len(got) != 1 || got[0] != "references/doc.md" {
|
|
t.Errorf("files = %v, want [references/doc.md]", got)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_NoSkillMd(t *testing.T) {
|
|
data := buildTestZip(t, map[string]string{
|
|
"my-skill/notes.md": "hello",
|
|
})
|
|
if _, err := parseSkillArchive(data, "x.skill"); err == nil {
|
|
t.Fatal("expected error when archive has no SKILL.md")
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_InvalidZip(t *testing.T) {
|
|
if _, err := parseSkillArchive([]byte("not a zip"), "x.skill"); err == nil {
|
|
t.Fatal("expected error for non-zip data")
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_RejectsUnsafeSkillMdPath(t *testing.T) {
|
|
// A SKILL.md whose only candidate path is absolute or traversal must not be
|
|
// accepted as the primary content; the archive is treated as having none.
|
|
for _, name := range []string{"../escape/SKILL.md", "/abs/SKILL.md"} {
|
|
data := buildTestZip(t, map[string]string{name: testSkillMd})
|
|
if _, err := parseSkillArchive(data, "x.skill"); err == nil {
|
|
t.Errorf("expected rejection for unsafe SKILL.md path %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_DropsTraversalAndJunk(t *testing.T) {
|
|
data := buildTestZip(t, map[string]string{
|
|
"s/SKILL.md": testSkillMd,
|
|
"s/../evil.sh": "pwn", // zip-slip out of the skill root
|
|
"s/.git/config": "secret", // dotfile dir
|
|
"s/.DS_Store": "junk", // dotfile
|
|
"__MACOSX/s/._x": "applemeta", // mac noise (outside root anyway)
|
|
"s/LICENSE": "MIT", // license excluded
|
|
"s/keep.md": "real", // legitimate asset
|
|
})
|
|
imported, err := parseSkillArchive(data, "s.skill")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
got := filePaths(imported)
|
|
if len(got) != 1 || got[0] != "keep.md" {
|
|
t.Fatalf("files = %v, want only [keep.md]", got)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_SkipsBinaryAssets(t *testing.T) {
|
|
data := buildTestZip(t, map[string]string{
|
|
"s/SKILL.md": testSkillMd,
|
|
"s/logo.png": "\x89PNG\x00binary",
|
|
"s/note.txt": "text",
|
|
})
|
|
imported, err := parseSkillArchive(data, "s.skill")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
if got := filePaths(imported); len(got) != 1 || got[0] != "note.txt" {
|
|
t.Errorf("files = %v, want [note.txt] (binary png dropped)", got)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_NameFallbackToWrapperDir(t *testing.T) {
|
|
noName := "# Title only\n\nNo frontmatter name here.\n"
|
|
data := buildTestZip(t, map[string]string{
|
|
"cool-skill/SKILL.md": noName,
|
|
})
|
|
imported, err := parseSkillArchive(data, "ignored.skill")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
if imported.name != "cool-skill" {
|
|
t.Errorf("name = %q, want cool-skill (wrapper dir fallback)", imported.name)
|
|
}
|
|
}
|
|
|
|
func TestParseSkillArchive_NameFallbackToFilename(t *testing.T) {
|
|
noName := "# Title only\n"
|
|
data := buildTestZip(t, map[string]string{
|
|
"SKILL.md": noName,
|
|
})
|
|
imported, err := parseSkillArchive(data, "My-Thing.skill")
|
|
if err != nil {
|
|
t.Fatalf("parseSkillArchive: %v", err)
|
|
}
|
|
if imported.name != "My-Thing" {
|
|
t.Errorf("name = %q, want My-Thing (filename fallback)", imported.name)
|
|
}
|
|
}
|
|
|
|
// --- Handler-level tests: the multipart /api/skills/import archive path ---
|
|
|
|
func skillMdWithName(name, desc string) string {
|
|
return "---\nname: " + name + "\ndescription: " + desc + "\n---\n\n# " + name + "\n\nBody.\n"
|
|
}
|
|
|
|
func newSkillArchiveImportRequest(userID string, archive []byte, filename, onConflict string) *http.Request {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
part, _ := writer.CreateFormFile("file", filename)
|
|
_, _ = part.Write(archive)
|
|
if onConflict != "" {
|
|
_ = writer.WriteField("on_conflict", onConflict)
|
|
}
|
|
_ = writer.Close()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &buf)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.Header.Set("X-User-ID", userID)
|
|
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
|
return req
|
|
}
|
|
|
|
func TestImportSkill_ArchiveUploadCreatesSkill(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("handler test DB not configured")
|
|
}
|
|
name := "archive-create-" + t.Name()
|
|
archive := buildTestZip(t, map[string]string{
|
|
name + "/SKILL.md": skillMdWithName(name, "From archive"),
|
|
name + "/scripts/run.sh": "echo hi",
|
|
})
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM skill WHERE workspace_id = $1 AND name = $2`, testWorkspaceID, name)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, archive, name+".skill", "fail"))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", w.Code, w.Body.String())
|
|
}
|
|
var body SkillImportResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Status != "created" || body.Skill == nil {
|
|
t.Fatalf("body = %#v", body)
|
|
}
|
|
if body.Skill.Name != name {
|
|
t.Errorf("name = %q, want %q", body.Skill.Name, name)
|
|
}
|
|
found := false
|
|
for _, f := range body.Skill.Files {
|
|
if f.Path == "scripts/run.sh" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("scripts/run.sh missing from imported files: %#v", body.Skill.Files)
|
|
}
|
|
}
|
|
|
|
func TestImportSkill_ArchiveUploadConflictSkip(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("handler test DB not configured")
|
|
}
|
|
namePrefix := "archive-skip"
|
|
skillName := namePrefix + "-" + t.Name()
|
|
existingID := insertHandlerTestSkill(t, namePrefix, "# Existing")
|
|
archive := buildTestZip(t, map[string]string{
|
|
skillName + "/SKILL.md": skillMdWithName(skillName, "From archive"),
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, archive, skillName+".skill", "skip"))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
var body SkillImportResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Status != "skipped" {
|
|
t.Fatalf("status = %q, want skipped", body.Status)
|
|
}
|
|
if body.ExistingSkill == nil || body.ExistingSkill.ID != existingID {
|
|
t.Fatalf("existing_skill = %#v", body.ExistingSkill)
|
|
}
|
|
}
|
|
|
|
func TestImportSkill_ArchiveUploadRejectsNonZip(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("handler test DB not configured")
|
|
}
|
|
w := httptest.NewRecorder()
|
|
testHandler.ImportSkill(w, newSkillArchiveImportRequest(testUserID, []byte("not a zip at all"), "bad.skill", "fail"))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|