Files
multica/server/internal/handler/skill_import_duplicate_test.go
Bohan Jiang e4ec9dc425 MUL-2802: add skill import conflict strategies (#3997)
* feat(skills): structured conflict + overwrite path for local skill re-import

Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.

- New terminal import status `conflict` carrying { existing_skill_id,
  existing_created_by, can_overwrite }; can_overwrite = requester is the
  skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
  canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
  once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
  constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
  both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
  payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
  existence (workspace-scoped) and creator permission, then replaces
  description/content/config and fully replaces files (pruning files absent
  from the new bundle). Preserves id, created_by, created_at, name, and
  agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
  create-by-name); any mid-write error rolls back the tx, leaving the original
  skill untouched. Retrying a terminal request is a no-op.

Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.

Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).

MUL-2800

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name

Addresses review feedback on PR #3498 (MUL-2800).

Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.

Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.

Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.

Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.

MUL-2800

Co-authored-by: multica-agent <github@multica.ai>

* feat(skills): resolve local import conflicts in desktop

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): preserve bulk flow after conflict resolution

Co-authored-by: multica-agent <github@multica.ai>

* feat(cli): add skill import conflict strategies

Co-authored-by: multica-agent <github@multica.ai>

* fix(i18n): sync skill import locale keys

Co-authored-by: multica-agent <github@multica.ai>

* docs: explain skill import conflict handling

Co-authored-by: multica-agent <github@multica.ai>

* docs: refresh skill import source map anchors

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 13:00:56 +08:00

150 lines
4.6 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExistingSkillIdentityByNameReturnsIDAndName(t *testing.T) {
namePrefix := "duplicate-import-identity"
name := namePrefix + "-" + t.Name()
skillID := insertHandlerTestSkill(t, namePrefix, "# Duplicate import identity")
existing, ok, err := testHandler.existingSkillIdentityByName(context.Background(), parseUUID(testWorkspaceID), name)
if err != nil {
t.Fatalf("existingSkillIdentityByName: %v", err)
}
if !ok {
t.Fatal("expected existing skill identity to be found")
}
if existing.ID != skillID || existing.Name != name {
t.Fatalf("existing skill = %#v, want id %s name %s", existing, skillID, name)
}
}
func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
w := httptest.NewRecorder()
writeSkillImportDuplicateConflict(w, ExistingSkillIdentity{ID: "skill-123", Name: "review-helper"})
if w.Code != 409 {
t.Fatalf("status = %d, want 409: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", body["error"])
}
existing, ok := body["existing_skill"].(map[string]any)
if !ok {
t.Fatalf("existing_skill missing or wrong type: %#v", body["existing_skill"])
}
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
t.Fatalf("existing_skill = %#v", existing)
}
}
func withMockClawHubImport(t *testing.T, skillName string) string {
t.Helper()
slug := "review-helper"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/" + slug:
_ = json.NewEncoder(w).Encode(map[string]any{
"skill": map[string]any{
"slug": slug,
"displayName": skillName,
"summary": "Imported test skill",
"tags": map[string]string{"latest": "1.0.0"},
},
})
case "/api/v1/skills/" + slug + "/versions/1.0.0":
_ = json.NewEncoder(w).Encode(map[string]any{
"version": map[string]any{
"version": "1.0.0",
"files": []map[string]any{
{"path": "SKILL.md", "size": 16},
},
},
})
case "/api/v1/skills/" + slug + "/file":
_, _ = w.Write([]byte("# Imported\n"))
default:
t.Fatalf("unexpected ClawHub path: %s", r.URL.String())
}
}))
prev := clawHubAPIBase
clawHubAPIBase = srv.URL + "/api/v1"
t.Cleanup(func() {
clawHubAPIBase = prev
srv.Close()
})
return "https://clawhub.ai/acme/" + slug
}
func TestImportSkillOnConflictSkipReturnsStructuredResult(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
namePrefix := "url-import-skip"
skillName := namePrefix + "-" + t.Name()
existingID := insertHandlerTestSkill(t, namePrefix, "# Existing")
importURL := withMockClawHubImport(t, skillName)
w := httptest.NewRecorder()
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
"url": importURL,
"on_conflict": "skip",
})
testHandler.ImportSkill(w, req)
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 body: %v", err)
}
if body.Status != "skipped" {
t.Fatalf("status = %q", body.Status)
}
if body.ExistingSkill == nil || body.ExistingSkill.ID != existingID || body.ExistingSkill.Name != skillName {
t.Fatalf("existing_skill = %#v", body.ExistingSkill)
}
}
func TestImportSkillOnConflictRenameCreatesSuffixedSkill(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
namePrefix := "url-import-rename"
skillName := namePrefix + "-" + t.Name()
insertHandlerTestSkill(t, namePrefix, "# Existing")
importURL := withMockClawHubImport(t, skillName)
w := httptest.NewRecorder()
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
"url": importURL,
"on_conflict": "rename",
})
testHandler.ImportSkill(w, req)
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 body: %v", err)
}
if body.Status != "created" || body.Skill == nil {
t.Fatalf("body = %#v", body)
}
if body.Skill.Name != skillName+"-2" {
t.Fatalf("created skill name = %q, want %q", body.Skill.Name, skillName+"-2")
}
}