fix(office): reject empty slug on skill update (#3509)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-09 14:51:55 +08:00
committed by GitHub
parent fc1fc3ac18
commit 769f2cc0e4
6 changed files with 297 additions and 8 deletions
@@ -187,6 +187,11 @@ func TestPollModeGrace_StopDisarmsTimer(t *testing.T) {
// it to return, and prevent the callback from changing the mode afterward.
func TestPollModeGrace_StopJoinsFinalScan(t *testing.T) {
wt := newGraceTestTracker(t, graceFiresQuickly)
// The test covers the grace callback's cancellation and wait-group
// ownership. Disable the independent polling loops so their real Git work
// cannot change poll mode while this lifecycle boundary is under test (the
// Windows race runner can otherwise report an unrelated paused transition).
wt.gitIndexPath = ""
finalScanStarted := make(chan struct{})
finalScanFinished := make(chan struct{})
wt.gitStatusObserver = func(ctx context.Context) (types.GitStatusUpdate, error) {
@@ -98,7 +98,7 @@ func (h *Handler) updateSkill(c *gin.Context) {
return
}
applySkillUpdates(skill, &req)
if err := h.svc.ValidateSkillUpdate(ctx, skill); err != nil {
if err := h.svc.ValidateSkillUpdate(ctx, skill, req.Slug != nil); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -0,0 +1,190 @@
package skills_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/skills"
)
// newTestSkillRouter mounts the skills routes over a fresh service.
func newTestSkillRouter(t *testing.T) (*gin.Engine, *skills.SkillService) {
t.Helper()
gin.SetMode(gin.TestMode)
svc := newTestSkillService(t)
router := gin.New()
skills.NewHandler(svc).RegisterRoutes(router.Group("/api/v1"))
return router, svc
}
func doSkillRequest(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestUpdateSkillHandler_RejectsEmptySlug(t *testing.T) {
router, svc := newTestSkillRouter(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"slug":""}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
reloaded, err := svc.GetSkillFromConfig(ctx, skill.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Slug != "kandev-existing" {
t.Errorf("stored slug = %q, want unchanged %q", reloaded.Slug, "kandev-existing")
}
}
func TestUpdateSkillHandler_RejectsNotWellFormedSlug(t *testing.T) {
router, svc := newTestSkillRouter(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"slug":"not a valid slug!"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
reloaded, err := svc.GetSkillFromConfig(ctx, skill.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Slug != "kandev-existing" {
t.Errorf("stored slug = %q, want unchanged %q", reloaded.Slug, "kandev-existing")
}
}
func TestUpdateSkillHandler_ContentOnlyPatchHealsStoredEmptySlug(t *testing.T) {
router, svc := newTestSkillRouter(t)
ctx := context.Background()
// Bypasses ValidateAndPrepareSkill, mirroring config-import's CreateSkill
// call, to reproduce a durable row with an empty stored slug.
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", Slug: "", SourceType: "inline"}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"content":"new content"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skills.SkillResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
}
if resp.Skill.Slug == "" {
t.Errorf("response slug still empty after content-only PATCH")
}
reloaded, err := svc.GetSkillFromConfig(ctx, skill.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Slug == "" {
t.Errorf("stored slug still empty after content-only PATCH")
}
}
func TestUpdateSkillHandler_ContentOnlyPatchDoesNotFailOnSlugCollision(t *testing.T) {
router, svc := newTestSkillRouter(t)
ctx := context.Background()
occupant := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, occupant); err != nil {
t.Fatalf("validate occupant: %v", err)
}
if err := svc.CreateSkill(ctx, occupant); err != nil {
t.Fatalf("create occupant: %v", err)
}
// Bypasses ValidateAndPrepareSkill, mirroring config-import's CreateSkill
// call, to reproduce a durable row with an empty stored slug whose
// name-derived candidate collides with occupant's slug.
broken := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", Slug: "", SourceType: "inline"}
if err := svc.CreateSkill(ctx, broken); err != nil {
t.Fatalf("create broken: %v", err)
}
rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+broken.ID, `{"content":"new content"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusOK, rec.Body.String())
}
reloaded, err := svc.GetSkillFromConfig(ctx, broken.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Slug != "" {
t.Errorf("stored slug = %q, want left empty on collision", reloaded.Slug)
}
if reloaded.Content != "new content" {
t.Errorf("content = %q, want %q", reloaded.Content, "new content")
}
}
func TestUpdateSkillHandler_OmittedSlugLeavesItUnchanged(t *testing.T) {
router, svc := newTestSkillRouter(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"content":"new content"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skills.SkillResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
}
if resp.Skill.Slug != "kandev-existing" {
t.Errorf("response slug = %q, want unchanged %q", resp.Skill.Slug, "kandev-existing")
}
reloaded, err := svc.GetSkillFromConfig(ctx, skill.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Slug != "kandev-existing" {
t.Errorf("stored slug = %q, want unchanged %q", reloaded.Slug, "kandev-existing")
}
}
+23 -5
View File
@@ -169,11 +169,20 @@ func prepareSkillPackageMetadata(skill *models.Skill) {
}
// ValidateSkillUpdate validates a skill update for slug uniqueness. Slug
// handling mirrors ValidateAndPrepareSkill: reject a not-well-formed slug
// outright (AC-001.11), otherwise normalize to canonical before the
// uniqueness check (AC-001.12).
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill) error {
if skill.Slug == "" {
// handling mirrors ValidateAndPrepareSkill: an empty slug is not
// well-formed and is rejected like any other not-well-formed slug when the
// caller supplies one; the caller omits the field entirely (slugRequested
// false) to leave the slug unchanged. A well-formed slug is normalized to
// canonical form before the uniqueness check runs.
//
// A request that never mentions slug must never fail on slug grounds. The
// config-import path can create a row with an empty stored slug outside
// this service's own validation, so an unrelated update to such a row
// heals it to a free name-derived slug rather than rejecting the caller's
// edit; if every candidate collides, the stored empty value is left as-is.
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill, slugRequested bool) error {
if !slugRequested && skill.Slug == "" {
s.healEmptySlug(ctx, skill)
return nil
}
if !skillslug.WellFormed(skill.Slug) {
@@ -183,6 +192,15 @@ func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Sk
return s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, skill.ID)
}
// healEmptySlug assigns skill a name-derived slug when one is free in its
// workspace, and leaves its stored empty slug untouched otherwise.
func (s *SkillService) healEmptySlug(ctx context.Context, skill *models.Skill) {
candidate := skillslug.Normalize(GenerateSlug(skill.Name))
if s.validateSlugUnique(ctx, skill.WorkspaceID, candidate, skill.ID) == nil {
skill.Slug = candidate
}
}
func (s *SkillService) validateSlugUnique(ctx context.Context, workspaceID, slug, excludeID string) error {
skills, err := s.repo.ListSkills(ctx, workspaceID)
if err != nil {
@@ -0,0 +1,76 @@
package skills_test
import (
"context"
"testing"
"github.com/kandev/kandev/internal/office/models"
)
func TestValidateSkillUpdate_RejectsEmptySlug(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
skill.Slug = ""
if err := svc.ValidateSkillUpdate(ctx, skill, true); err == nil {
t.Fatal("expected error for empty slug")
}
if skill.Slug != "" {
t.Errorf("slug = %q, want unchanged empty string, not coerced", skill.Slug)
}
}
func TestValidateSkillUpdate_HealsEmptyStoredSlugWhenNotRequested(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
// Bypasses ValidateAndPrepareSkill, mirroring config-import's CreateSkill
// call, to reproduce a durable row with an empty stored slug.
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
if err := svc.ValidateSkillUpdate(ctx, skill, false); err != nil {
t.Fatalf("update: %v", err)
}
if skill.Slug == "" {
t.Error("slug still empty after healing an unrequested update")
}
}
func TestValidateSkillUpdate_LeavesEmptyStoredSlugOnCollisionWhenNotRequested(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
occupant := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, occupant); err != nil {
t.Fatalf("validate occupant: %v", err)
}
if err := svc.CreateSkill(ctx, occupant); err != nil {
t.Fatalf("create occupant: %v", err)
}
// Bypasses ValidateAndPrepareSkill, mirroring config-import's CreateSkill
// call, to reproduce a durable row with an empty stored slug whose
// name-derived candidate collides with an existing skill.
broken := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
if err := svc.CreateSkill(ctx, broken); err != nil {
t.Fatalf("create broken: %v", err)
}
if err := svc.ValidateSkillUpdate(ctx, broken, false); err != nil {
t.Fatalf("update: %v", err)
}
if broken.Slug != "" {
t.Errorf("slug = %q, want left empty on collision", broken.Slug)
}
}
@@ -247,7 +247,7 @@ func TestValidateSkillUpdate_NormalizesWellFormedSlugToCanonical(t *testing.T) {
}
skill.Slug = "renamed"
if err := svc.ValidateSkillUpdate(ctx, skill); err != nil {
if err := svc.ValidateSkillUpdate(ctx, skill, true); err != nil {
t.Fatalf("update: %v", err)
}
if skill.Slug != "kandev-renamed" {
@@ -268,7 +268,7 @@ func TestValidateSkillUpdate_RejectsNotWellFormedSlug(t *testing.T) {
}
skill.Slug = "not a valid slug!"
if err := svc.ValidateSkillUpdate(ctx, skill); err == nil {
if err := svc.ValidateSkillUpdate(ctx, skill, true); err == nil {
t.Fatal("expected error for not-well-formed slug")
}
}