mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5a8d0016a5
> Mux updated this PR on behalf of Mike. ## Stack Context This PR is the storage, permissions, API, and SDK layer for experimental personal skills. #25362 has landed on `main`, so this branch is restacked directly on `main`. Stack order: 1. #25363 storage, permissions, API, and SDK 2. #25365 API test coverage 3. #25366 chattool and chatd integration 4. #25066 settings UI and docs 5. #25386 personal skills slash menu ## What? Adds the `user_skills` database table, generated queries, RBAC resources and scopes, audit resource handling, experimental user-scoped CRUD endpoints, SDK types, and generated API/site types. Follow-up review and restack fixes: - Enforce a bounded personal skill description in parser and database constraints. - Return `403 Forbidden` for unauthorized create and update attempts. - Return explicit conflict responses when soft-deleted users are targeted. - Keep user admins out of personal skills, while site owners can read and delete but not create or update. - Document trigger-raised constraint names and keep schema constants covered by tests. - Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type. - Generate user skill IDs in Go instead of relying on a database default. - Rebase on latest `main` and renumber the user skills migration to `000502_user_skills`. ## Why? Personal skills need durable user-owned storage with owner authorization, limited site-owner moderation, and a hidden API surface before chatd can consume them. ## Validation - `make gen` - `go test ./coderd/database -run '^TestUserSkillSchemaConstants$' -count=1` - `go test ./coderd/database/dbauthz -run '^TestMethodTestSuite/TestUserSkills$' -count=1` - `go test ./coderd -run '^TestPatchUserSkill$' -count=1` - `go test ./codersdk ./coderd/database/db2sdk` - `make lint` - pre-commit hook on `97fd58108d`
340 lines
8.8 KiB
Go
340 lines
8.8 KiB
Go
package skills_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/x/skills"
|
|
)
|
|
|
|
func TestParsePersonalSkillMarkdown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ValidWithDescription", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
content, err := skills.ParsePersonalSkillMarkdown([]byte(
|
|
"---\nname: my-skill\ndescription: Does a thing\n---\nUse this skill.\n",
|
|
))
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "my-skill", content.Name)
|
|
require.Equal(t, "Does a thing", content.Description)
|
|
require.Equal(t, skills.SourcePersonal, content.Source)
|
|
require.Equal(t, "Use this skill.", content.Body)
|
|
})
|
|
|
|
t.Run("ValidWithoutDescription", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
content, err := skills.ParsePersonalSkillMarkdown([]byte(
|
|
"---\nname: my-skill\n---\nUse this skill.\n",
|
|
))
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "my-skill", content.Name)
|
|
require.Empty(t, content.Description)
|
|
require.Equal(t, skills.SourcePersonal, content.Source)
|
|
require.Equal(t, "Use this skill.", content.Body)
|
|
})
|
|
|
|
t.Run("MissingOpeningDelimiter", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte("name: my-skill\n---\nBody.\n"))
|
|
|
|
require.ErrorContains(t, err, "missing opening frontmatter delimiter")
|
|
})
|
|
|
|
t.Run("MissingClosingDelimiter", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte("---\nname: my-skill\nBody.\n"))
|
|
|
|
require.ErrorContains(t, err, "missing closing frontmatter delimiter")
|
|
})
|
|
|
|
t.Run("MissingName", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte(
|
|
"---\ndescription: No name\n---\nBody.\n",
|
|
))
|
|
|
|
require.ErrorIs(t, err, skills.ErrInvalidSkillName)
|
|
require.ErrorContains(t, err, "frontmatter must contain a 'name' field")
|
|
})
|
|
|
|
t.Run("NonKebabCaseName", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte(
|
|
"---\nname: Not_Kebab\n---\nBody.\n",
|
|
))
|
|
|
|
require.ErrorIs(t, err, skills.ErrInvalidSkillName)
|
|
require.ErrorContains(t, err, "Not_Kebab")
|
|
})
|
|
|
|
t.Run("NameTooLong", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte(personalSkillMarkdownForTest(
|
|
strings.Repeat("a", skills.MaxPersonalSkillNameBytes+1),
|
|
"Too long",
|
|
"Body.",
|
|
)))
|
|
|
|
require.ErrorIs(t, err, skills.ErrInvalidSkillName)
|
|
require.ErrorContains(t, err, "maximum is 256 bytes")
|
|
})
|
|
|
|
t.Run("DescriptionTooLong", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte(personalSkillMarkdownForTest(
|
|
"my-skill",
|
|
strings.Repeat("a", skills.MaxPersonalSkillDescriptionBytes+1),
|
|
"Body.",
|
|
)))
|
|
|
|
require.ErrorIs(t, err, skills.ErrSkillDescriptionTooLarge)
|
|
require.ErrorContains(t, err, "maximum is 4096 bytes")
|
|
})
|
|
|
|
t.Run("EmptyBody", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.ParsePersonalSkillMarkdown([]byte(
|
|
"---\nname: my-skill\n---\n\n",
|
|
))
|
|
|
|
require.ErrorIs(t, err, skills.ErrSkillBodyRequired)
|
|
require.ErrorContains(t, err, "my-skill")
|
|
})
|
|
|
|
t.Run("OversizedContent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := []byte(strings.Repeat("a", skills.MaxPersonalSkillSizeBytes+1))
|
|
_, err := skills.ParsePersonalSkillMarkdown(raw)
|
|
|
|
require.ErrorIs(t, err, skills.ErrSkillTooLarge)
|
|
})
|
|
}
|
|
|
|
func personalSkillMarkdownForTest(name string, description string, body string) string {
|
|
return "---\nname: " + name + "\ndescription: " + description + "\n---\n\n" + body + "\n"
|
|
}
|
|
|
|
func TestMergeSkills(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("PersonalOnlyUsesBareAlias", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{{Name: "my-skill", Description: "Mine"}},
|
|
nil,
|
|
)
|
|
|
|
require.Equal(t, []skills.ResolvedSkill{{
|
|
Skill: skills.Skill{
|
|
Name: "my-skill",
|
|
Description: "Mine",
|
|
Source: skills.SourcePersonal,
|
|
},
|
|
Alias: "my-skill",
|
|
}}, resolved)
|
|
})
|
|
|
|
t.Run("WorkspaceOnlyUsesBareAlias", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
nil,
|
|
[]skills.Skill{{Name: "my-skill", Description: "Workspace"}},
|
|
)
|
|
|
|
require.Equal(t, []skills.ResolvedSkill{{
|
|
Skill: skills.Skill{
|
|
Name: "my-skill",
|
|
Description: "Workspace",
|
|
Source: skills.SourceWorkspace,
|
|
},
|
|
Alias: "my-skill",
|
|
}}, resolved)
|
|
})
|
|
|
|
t.Run("NonCollidingSkillsUseBareAliases", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{{Name: "personal-skill"}},
|
|
[]skills.Skill{{Name: "workspace-skill"}},
|
|
)
|
|
|
|
require.Equal(t, []skills.ResolvedSkill{
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "personal-skill",
|
|
Source: skills.SourcePersonal,
|
|
},
|
|
Alias: "personal-skill",
|
|
},
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "workspace-skill",
|
|
Source: skills.SourceWorkspace,
|
|
},
|
|
Alias: "workspace-skill",
|
|
},
|
|
}, resolved)
|
|
})
|
|
|
|
t.Run("CollidingSkillsUseQualifiedAliases", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{{Name: "shared-skill", Description: "Mine"}},
|
|
[]skills.Skill{{Name: "shared-skill", Description: "Workspace"}},
|
|
)
|
|
|
|
require.Equal(t, []skills.ResolvedSkill{
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "shared-skill",
|
|
Description: "Mine",
|
|
Source: skills.SourcePersonal,
|
|
},
|
|
Alias: "personal/shared-skill",
|
|
},
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "shared-skill",
|
|
Description: "Workspace",
|
|
Source: skills.SourceWorkspace,
|
|
},
|
|
Alias: "workspace/shared-skill",
|
|
},
|
|
}, resolved)
|
|
|
|
personal, err := skills.Lookup(resolved, "personal/shared-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourcePersonal, personal.Source)
|
|
require.Equal(t, "shared-skill", personal.Name)
|
|
|
|
workspace, err := skills.Lookup(resolved, "workspace/shared-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
|
require.Equal(t, "shared-skill", workspace.Name)
|
|
|
|
_, err = skills.Lookup(resolved, "shared-skill")
|
|
require.ErrorIs(t, err, skills.ErrSkillAmbiguous)
|
|
require.ErrorContains(t, err, "personal/shared-skill")
|
|
require.ErrorContains(t, err, "workspace/shared-skill")
|
|
})
|
|
|
|
t.Run("DuplicatesWithinSourceKeepFirst", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{
|
|
{Name: "duplicate-skill", Description: "First"},
|
|
{Name: "duplicate-skill", Description: "Second"},
|
|
},
|
|
[]skills.Skill{
|
|
{Name: "workspace-skill", Description: "Workspace"},
|
|
{Name: "workspace-skill", Description: "Workspace duplicate"},
|
|
},
|
|
)
|
|
|
|
require.Equal(t, []skills.ResolvedSkill{
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "duplicate-skill",
|
|
Description: "First",
|
|
Source: skills.SourcePersonal,
|
|
},
|
|
Alias: "duplicate-skill",
|
|
},
|
|
{
|
|
Skill: skills.Skill{
|
|
Name: "workspace-skill",
|
|
Description: "Workspace",
|
|
Source: skills.SourceWorkspace,
|
|
},
|
|
Alias: "workspace-skill",
|
|
},
|
|
}, resolved)
|
|
})
|
|
}
|
|
|
|
func TestLookup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("BareNameOnNonCollidingSkill", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{{Name: "personal-skill"}},
|
|
[]skills.Skill{{Name: "workspace-skill"}},
|
|
)
|
|
|
|
personal, err := skills.Lookup(resolved, "personal-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourcePersonal, personal.Source)
|
|
require.Equal(t, "personal-skill", personal.Name)
|
|
|
|
workspace, err := skills.Lookup(resolved, "workspace-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
|
require.Equal(t, "workspace-skill", workspace.Name)
|
|
})
|
|
|
|
t.Run("QualifiedAliasWorksWithoutCollision", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := skills.MergeSkills(
|
|
[]skills.Skill{{Name: "personal-skill"}},
|
|
[]skills.Skill{{Name: "workspace-skill"}},
|
|
)
|
|
|
|
personal, err := skills.Lookup(resolved, "personal/personal-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourcePersonal, personal.Source)
|
|
require.Equal(t, "personal-skill", personal.Name)
|
|
|
|
workspace, err := skills.Lookup(resolved, "workspace/workspace-skill")
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
|
require.Equal(t, "workspace-skill", workspace.Name)
|
|
})
|
|
|
|
t.Run("BareNameFallsBackToSingleQualifiedAliasMatch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolved := []skills.ResolvedSkill{{
|
|
Skill: skills.Skill{Name: "personal-skill", Source: skills.SourcePersonal},
|
|
Alias: "personal/personal-skill",
|
|
}}
|
|
|
|
personal, err := skills.Lookup(resolved, "personal-skill")
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, skills.SourcePersonal, personal.Source)
|
|
require.Equal(t, "personal-skill", personal.Name)
|
|
})
|
|
|
|
t.Run("UnknownLookupReturnsNotFound", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := skills.Lookup(nil, "missing-skill")
|
|
|
|
require.ErrorIs(t, err, skills.ErrSkillNotFound)
|
|
require.ErrorContains(t, err, "missing-skill")
|
|
})
|
|
}
|