mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Skills gain two independent settings. action marks a project skill as a one-click action above project composers; a check constraint keeps it on project skills, and personal and organization routes reject the field. model_invocable controls whether agents see and load a skill on their own: skills with it off are left out of <available-skills> and read_skill, but structured skill invocations still load them.
782 lines
29 KiB
Go
782 lines
29 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/x/skills"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// skillOwnerAPI drives the personal or organization skill routes through the
|
|
// same calls, so behavior both owners share is asserted for each of them.
|
|
type skillOwnerAPI struct {
|
|
path string
|
|
resourceType database.ResourceType
|
|
auditOrgID uuid.UUID
|
|
client *codersdk.ExperimentalClient
|
|
create func(context.Context, codersdk.CreateSkillRequest) (codersdk.Skill, error)
|
|
list func(context.Context) ([]codersdk.SkillMetadata, error)
|
|
get func(context.Context, string) (codersdk.Skill, error)
|
|
update func(context.Context, string, codersdk.UpdateSkillRequest) (codersdk.Skill, error)
|
|
delete func(context.Context, string) error
|
|
}
|
|
|
|
func personalSkillAPI(client *codersdk.ExperimentalClient) skillOwnerAPI {
|
|
return skillOwnerAPI{
|
|
path: "/api/experimental/users/me/skills",
|
|
resourceType: database.ResourceTypeUserSkill,
|
|
client: client,
|
|
create: func(ctx context.Context, req codersdk.CreateSkillRequest) (codersdk.Skill, error) {
|
|
return client.CreateUserSkill(ctx, codersdk.Me, req)
|
|
},
|
|
list: func(ctx context.Context) ([]codersdk.SkillMetadata, error) {
|
|
return client.UserSkills(ctx, codersdk.Me)
|
|
},
|
|
get: func(ctx context.Context, name string) (codersdk.Skill, error) {
|
|
return client.UserSkillByName(ctx, codersdk.Me, name)
|
|
},
|
|
update: func(ctx context.Context, name string, req codersdk.UpdateSkillRequest) (codersdk.Skill, error) {
|
|
return client.UpdateUserSkill(ctx, codersdk.Me, name, req)
|
|
},
|
|
delete: func(ctx context.Context, name string) error { return client.DeleteUserSkill(ctx, codersdk.Me, name) },
|
|
}
|
|
}
|
|
|
|
func organizationSkillAPI(client *codersdk.ExperimentalClient, orgID uuid.UUID) skillOwnerAPI {
|
|
return skillOwnerAPI{
|
|
path: fmt.Sprintf("/api/experimental/organizations/%s/skills", orgID),
|
|
resourceType: database.ResourceTypeOrganizationSkill,
|
|
auditOrgID: orgID,
|
|
client: client,
|
|
create: func(ctx context.Context, req codersdk.CreateSkillRequest) (codersdk.Skill, error) {
|
|
return client.CreateOrganizationSkill(ctx, orgID, req)
|
|
},
|
|
list: func(ctx context.Context) ([]codersdk.SkillMetadata, error) {
|
|
return client.OrganizationSkills(ctx, orgID)
|
|
},
|
|
get: func(ctx context.Context, name string) (codersdk.Skill, error) {
|
|
return client.OrganizationSkillByName(ctx, orgID, name)
|
|
},
|
|
update: func(ctx context.Context, name string, req codersdk.UpdateSkillRequest) (codersdk.Skill, error) {
|
|
return client.UpdateOrganizationSkill(ctx, orgID, name, req)
|
|
},
|
|
delete: func(ctx context.Context, name string) error { return client.DeleteOrganizationSkill(ctx, orgID, name) },
|
|
}
|
|
}
|
|
|
|
// skillOwnerCases each start a deployment and return a caller that manages
|
|
// one owner's skills, plus a peer that manages another owner of the same kind.
|
|
var skillOwnerCases = []struct {
|
|
name string
|
|
setup func(t *testing.T, opts *coderdtest.Options) (owner, peer skillOwnerAPI)
|
|
}{
|
|
{
|
|
name: "Personal",
|
|
setup: func(t *testing.T, opts *coderdtest.Options) (skillOwnerAPI, skillOwnerAPI) {
|
|
adminClient := coderdtest.New(t, opts)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
peerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
return personalSkillAPI(codersdk.NewExperimentalClient(ownerClient)), personalSkillAPI(codersdk.NewExperimentalClient(peerClient))
|
|
},
|
|
},
|
|
{
|
|
name: "OrgAdmin",
|
|
setup: func(t *testing.T, opts *coderdtest.Options) (skillOwnerAPI, skillOwnerAPI) {
|
|
ownerClient, db := coderdtest.NewWithDatabase(t, opts)
|
|
firstUser := coderdtest.CreateFirstUser(t, ownerClient)
|
|
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
|
|
peerOrg := dbgen.Organization(t, db, database.Organization{})
|
|
return organizationSkillAPI(codersdk.NewExperimentalClient(orgAdminClient), firstUser.OrganizationID),
|
|
organizationSkillAPI(codersdk.NewExperimentalClient(ownerClient), peerOrg.ID)
|
|
},
|
|
},
|
|
{
|
|
name: "SiteOwner",
|
|
setup: func(t *testing.T, opts *coderdtest.Options) (skillOwnerAPI, skillOwnerAPI) {
|
|
ownerClient, db := coderdtest.NewWithDatabase(t, opts)
|
|
firstUser := coderdtest.CreateFirstUser(t, ownerClient)
|
|
client := codersdk.NewExperimentalClient(ownerClient)
|
|
peerOrg := dbgen.Organization(t, db, database.Organization{})
|
|
return organizationSkillAPI(client, firstUser.OrganizationID), organizationSkillAPI(client, peerOrg.ID)
|
|
},
|
|
},
|
|
}
|
|
|
|
func TestSkillsCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range skillOwnerCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
owner, _ := tc.setup(t, nil)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
emptyList, err := owner.list(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, emptyList)
|
|
assert.Empty(t, emptyList)
|
|
rawEmptyList := requireRawSkillList(ctx, t, owner)
|
|
assert.NotNil(t, rawEmptyList)
|
|
assert.Empty(t, rawEmptyList)
|
|
|
|
content := userSkillMarkdown("crud-skill", "Initial description", "Use this skill for CRUD tests.")
|
|
created, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: content})
|
|
require.NoError(t, err)
|
|
assert.NotZero(t, created.ID)
|
|
assert.Equal(t, "crud-skill", created.Name)
|
|
assert.Equal(t, "Initial description", created.Description)
|
|
assert.Equal(t, content, created.Content)
|
|
assert.True(t, created.Enabled)
|
|
assert.True(t, created.ModelInvocable)
|
|
assert.False(t, created.Action)
|
|
assert.NotZero(t, created.CreatedAt)
|
|
assert.NotZero(t, created.UpdatedAt)
|
|
|
|
list, err := owner.list(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
assert.Equal(t, created.ID, list[0].ID)
|
|
assert.Equal(t, "crud-skill", list[0].Name)
|
|
assert.Equal(t, "Initial description", list[0].Description)
|
|
assert.True(t, list[0].Enabled)
|
|
rawList := requireRawSkillList(ctx, t, owner)
|
|
require.Len(t, rawList, 1)
|
|
assert.NotContains(t, rawList[0], "content")
|
|
|
|
got, err := owner.get(ctx, "crud-skill")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, got.ID)
|
|
assert.Equal(t, content, got.Content)
|
|
|
|
updatedContent := userSkillMarkdown("crud-skill", "Updated description", "Updated body.")
|
|
updated, err := owner.update(ctx, "crud-skill", codersdk.UpdateSkillRequest{Content: ptr.Ref(updatedContent)})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, updated.ID)
|
|
assert.Equal(t, "Updated description", updated.Description)
|
|
assert.Equal(t, updatedContent, updated.Content)
|
|
assert.True(t, updated.Enabled)
|
|
|
|
disabled, err := owner.update(ctx, "crud-skill", codersdk.UpdateSkillRequest{Enabled: ptr.Ref(false)})
|
|
require.NoError(t, err)
|
|
assert.False(t, disabled.Enabled)
|
|
assert.Equal(t, updatedContent, disabled.Content)
|
|
assert.Equal(t, "Updated description", disabled.Description)
|
|
list, err = owner.list(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
assert.False(t, list[0].Enabled)
|
|
got, err = owner.get(ctx, "crud-skill")
|
|
require.NoError(t, err)
|
|
assert.False(t, got.Enabled)
|
|
|
|
manual, err := owner.update(ctx, "crud-skill", codersdk.UpdateSkillRequest{ModelInvocable: ptr.Ref(false)})
|
|
require.NoError(t, err)
|
|
assert.False(t, manual.ModelInvocable)
|
|
assert.False(t, manual.Enabled)
|
|
list, err = owner.list(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
assert.False(t, list[0].ModelInvocable)
|
|
|
|
require.NoError(t, owner.delete(ctx, "crud-skill"))
|
|
_, err = owner.get(ctx, "crud-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
})
|
|
}
|
|
}
|
|
|
|
func requireRawSkillList(ctx context.Context, t *testing.T, owner skillOwnerAPI) []map[string]json.RawMessage {
|
|
t.Helper()
|
|
res, err := owner.client.Request(ctx, http.MethodGet, owner.path, nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
var raw []map[string]json.RawMessage
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&raw))
|
|
return raw
|
|
}
|
|
|
|
func TestSkillValidationAndConflicts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range skillOwnerCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
owner, peer := tc.setup(t, nil)
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
expectedMessage string
|
|
}{
|
|
{
|
|
name: "MissingFrontmatterDelimiters",
|
|
content: "name: missing-frontmatter\n\nBody.",
|
|
expectedMessage: "Invalid skill content.",
|
|
},
|
|
{
|
|
name: "MissingName",
|
|
content: "---\n" +
|
|
"description: Missing name\n" +
|
|
"---\n\nBody.",
|
|
expectedMessage: "Invalid skill name.",
|
|
},
|
|
{
|
|
name: "NonKebabCaseName",
|
|
content: userSkillMarkdown("NotKebab", "Invalid", "Body."),
|
|
expectedMessage: "Invalid skill name.",
|
|
},
|
|
{
|
|
name: "NameTooLong",
|
|
content: userSkillMarkdown(strings.Repeat("a", skills.MaxPersonalSkillNameBytes+1), "Invalid", "Body."),
|
|
expectedMessage: "Invalid skill name.",
|
|
},
|
|
{
|
|
name: "EmptyBody",
|
|
content: userSkillMarkdown("empty-body", "Invalid", " \n"),
|
|
expectedMessage: "Skill body is required.",
|
|
},
|
|
{
|
|
name: "TooLarge",
|
|
content: strings.Repeat("a", skills.MaxPersonalSkillSizeBytes+1),
|
|
expectedMessage: "Skill content is too large.",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: tt.content})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, tt.expectedMessage, sdkErr.Message)
|
|
})
|
|
}
|
|
|
|
t.Run("PatchEmptyBody", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown("patch-validation", "Valid", "Body.")})
|
|
require.NoError(t, err)
|
|
_, err = owner.update(ctx, "patch-validation", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("patch-validation", "Invalid", " \n")),
|
|
})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, "Skill body is required.", sdkErr.Message)
|
|
})
|
|
|
|
t.Run("PatchNoFields", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown("patch-no-fields", "Valid", "Body.")})
|
|
require.NoError(t, err)
|
|
_, err = owner.update(ctx, "patch-no-fields", codersdk.UpdateSkillRequest{})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, "No skill fields to update.", sdkErr.Message)
|
|
})
|
|
|
|
// Only project skills can be actions, so personal and
|
|
// organization routes reject the field even when it is false.
|
|
t.Run("ActionRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
content := userSkillMarkdown("action-rejected", "Valid", "Body.")
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: content, Action: ptr.Ref(true)})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, "Only project skills can be actions.", sdkErr.Message)
|
|
_, err = owner.create(ctx, codersdk.CreateSkillRequest{Content: content, ModelInvocable: ptr.Ref(false)})
|
|
require.NoError(t, err)
|
|
for _, action := range []bool{true, false} {
|
|
_, err = owner.update(ctx, "action-rejected", codersdk.UpdateSkillRequest{Action: ptr.Ref(action)})
|
|
sdkErr = requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, "Only project skills can be actions.", sdkErr.Message)
|
|
}
|
|
})
|
|
|
|
t.Run("PatchNameMismatch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown("old-name", "Old", "Body.")})
|
|
require.NoError(t, err)
|
|
_, err = owner.update(ctx, "old-name", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("new-name", "New", "Body.")),
|
|
})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Equal(t, "Skill name in path does not match frontmatter name.", sdkErr.Message)
|
|
assert.Equal(t, `path has "old-name", frontmatter has "new-name"`, sdkErr.Detail)
|
|
})
|
|
|
|
t.Run("MissingSkill", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
_, err := owner.get(ctx, "missing-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = owner.update(ctx, "missing-skill", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("missing-skill", "Missing", "Body.")),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
err = owner.delete(ctx, "missing-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("SameNameConflictsOnlyWithinOwner", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
sharedContent := userSkillMarkdown("shared-skill", "Shared", "Shared body.")
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: sharedContent})
|
|
require.NoError(t, err)
|
|
_, err = owner.create(ctx, codersdk.CreateSkillRequest{Content: sharedContent})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusConflict)
|
|
assert.Equal(t, "A skill with that name already exists.", sdkErr.Message)
|
|
_, err = peer.create(ctx, codersdk.CreateSkillRequest{Content: sharedContent})
|
|
require.NoError(t, err)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUserSkillLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient := coderdtest.New(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
owner := codersdk.NewExperimentalClient(ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
for i := range skills.MaxPersonalSkillsPerUser {
|
|
name := fmt.Sprintf("limit-skill-%03d", i)
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown(name, "Limit", "Body."),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("limit-skill-overflow", "Limit", "Body."),
|
|
})
|
|
sdkErr := requireSDKErrorStatus(t, err, http.StatusConflict)
|
|
assert.Equal(t, "Personal skill limit reached.", sdkErr.Message)
|
|
assert.Equal(t,
|
|
fmt.Sprintf("Each user can have at most %d personal skills.", skills.MaxPersonalSkillsPerUser),
|
|
sdkErr.Detail,
|
|
)
|
|
}
|
|
|
|
func TestUserSkillLimitConcurrentCreates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient := coderdtest.New(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
owner := codersdk.NewExperimentalClient(ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
for i := range skills.MaxPersonalSkillsPerUser - 1 {
|
|
name := fmt.Sprintf("concurrent-limit-skill-%03d", i)
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown(name, "Limit", "Body."),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
const attempts = 8
|
|
start := make(chan struct{})
|
|
results := make(chan error, attempts)
|
|
for i := range attempts {
|
|
go func() {
|
|
<-start
|
|
name := fmt.Sprintf("concurrent-limit-overflow-%03d", i)
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown(name, "Limit", "Body."),
|
|
})
|
|
results <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
|
|
successes := 0
|
|
for range attempts {
|
|
err := <-results
|
|
if err == nil {
|
|
successes++
|
|
continue
|
|
}
|
|
requireSDKErrorStatus(t, err, http.StatusConflict)
|
|
}
|
|
assert.Equal(t, 1, successes)
|
|
|
|
list, err := owner.UserSkills(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
assert.Len(t, list, skills.MaxPersonalSkillsPerUser)
|
|
}
|
|
|
|
func TestUserSkillRequestAllowsEscapedMaxSizeContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient := coderdtest.New(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
owner := codersdk.NewExperimentalClient(ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
prefix := "---\nname: escaped-limit-skill\ndescription: Escaped\n---\n\n"
|
|
suffix := "\n"
|
|
bodyLen := skills.MaxPersonalSkillSizeBytes - len(prefix) - len(suffix)
|
|
require.Positive(t, bodyLen)
|
|
content := prefix + strings.Repeat(`"`, bodyLen) + suffix
|
|
require.Len(t, []byte(content), skills.MaxPersonalSkillSizeBytes)
|
|
|
|
raw, err := json.Marshal(codersdk.CreateSkillRequest{Content: content})
|
|
require.NoError(t, err)
|
|
require.Greater(t, len(raw), skills.MaxPersonalSkillSizeBytes+1024)
|
|
|
|
created, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: content,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "escaped-limit-skill", created.Name)
|
|
}
|
|
|
|
func TestUserSkillAuthorization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient := coderdtest.New(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, ownerUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
otherClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
userAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
|
|
admin := codersdk.NewExperimentalClient(adminClient)
|
|
owner := codersdk.NewExperimentalClient(ownerClient)
|
|
other := codersdk.NewExperimentalClient(otherClient)
|
|
userAdmin := codersdk.NewExperimentalClient(userAdminClient)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
targetUser := ownerUser.Username
|
|
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("auth-skill", "Auth", "Body."),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = other.UserSkills(ctx, targetUser)
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = other.UserSkillByName(ctx, targetUser, "auth-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = other.CreateUserSkill(ctx, targetUser, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("denied-create", "Denied", "Body."),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = other.UpdateUserSkill(ctx, targetUser, "auth-skill", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("auth-skill", "Denied", "Body.")),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
err = other.DeleteUserSkill(ctx, targetUser, "auth-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
|
|
_, err = userAdmin.UserSkills(ctx, targetUser)
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = userAdmin.UserSkillByName(ctx, targetUser, "auth-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
_, err = userAdmin.CreateUserSkill(ctx, targetUser, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("denied-admin-create", "Denied", "Body."),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusForbidden)
|
|
_, err = userAdmin.UpdateUserSkill(ctx, targetUser, "auth-skill", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("auth-skill", "Denied", "Body.")),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusForbidden)
|
|
err = userAdmin.DeleteUserSkill(ctx, targetUser, "auth-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
|
|
_, err = admin.CreateUserSkill(ctx, targetUser, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("admin-created", "Admin create", "Created by admin."),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusForbidden)
|
|
|
|
list, err := admin.UserSkills(ctx, targetUser)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
assert.Equal(t, "auth-skill", list[0].Name)
|
|
got, err := admin.UserSkillByName(ctx, targetUser, "auth-skill")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "auth-skill", got.Name)
|
|
_, err = admin.UpdateUserSkill(ctx, targetUser, "auth-skill", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("auth-skill", "Admin update", "Updated by admin.")),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusForbidden)
|
|
require.NoError(t, admin.DeleteUserSkill(ctx, targetUser, "auth-skill"))
|
|
}
|
|
|
|
func TestUserSkillSoftDeleteCleanup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient, _, api := coderdtest.NewWithAPI(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
ownerClient, ownerUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
owner := codersdk.NewExperimentalClient(ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := owner.CreateUserSkill(ctx, codersdk.Me, codersdk.CreateSkillRequest{
|
|
Content: userSkillMarkdown("soft-delete-skill", "Soft delete", "Body."),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, adminClient.DeleteUser(ctx, ownerUser.ID))
|
|
readAuthzCtx := dbauthz.AsSystemRestricted(ctx)
|
|
_, err = api.Database.GetUserSkillByUserIDAndName(
|
|
readAuthzCtx,
|
|
database.GetUserSkillByUserIDAndNameParams{
|
|
UserID: ownerUser.ID,
|
|
Name: "soft-delete-skill",
|
|
},
|
|
)
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
|
|
createAuthzCtx := dbauthz.As(ctx, rbac.Subject{
|
|
Type: rbac.SubjectTypeUser,
|
|
ID: ownerUser.ID.String(),
|
|
Roles: rbac.RoleIdentifiers{rbac.RoleMember()},
|
|
Scope: rbac.ScopeAll,
|
|
}.WithCachedASTValue())
|
|
_, err = api.Database.InsertUserSkill(
|
|
createAuthzCtx,
|
|
database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: ownerUser.ID,
|
|
Name: "after-soft-delete",
|
|
Description: "Soft delete",
|
|
Content: userSkillMarkdown("after-soft-delete", "Soft delete", "Body."),
|
|
},
|
|
)
|
|
require.True(t, database.IsCheckViolation(err, database.CheckConstraint("user_skill_user_deleted")))
|
|
require.ErrorContains(t, err, "Cannot create user_skill for deleted user")
|
|
}
|
|
|
|
func TestUserSkillDatabaseConstraints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adminClient, _, api := coderdtest.NewWithAPI(t, nil)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
_, ownerUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
tests := []struct {
|
|
name string
|
|
params database.InsertUserSkillParams
|
|
constraint database.CheckConstraint
|
|
}{
|
|
{
|
|
name: "NameFormat",
|
|
params: database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: ownerUser.ID,
|
|
Name: "not kebab",
|
|
Description: "Invalid",
|
|
Content: userSkillMarkdown("not kebab", "Invalid", "Body."),
|
|
},
|
|
constraint: database.CheckSkillsNameFormat,
|
|
},
|
|
{
|
|
name: "NameSize",
|
|
params: database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: ownerUser.ID,
|
|
Name: strings.Repeat("a", skills.MaxPersonalSkillNameBytes+1),
|
|
Description: "Invalid",
|
|
Content: userSkillMarkdown("too-long-name", "Invalid", "Body."),
|
|
},
|
|
constraint: database.CheckSkillsNameSize,
|
|
},
|
|
{
|
|
name: "ContentSize",
|
|
params: database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: ownerUser.ID,
|
|
Name: "content-too-large",
|
|
Description: "Invalid",
|
|
Content: strings.Repeat("a", skills.MaxPersonalSkillSizeBytes+1),
|
|
},
|
|
constraint: database.CheckSkillsContentSize,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
authzCtx := dbauthz.As(ctx, coderdtest.AuthzUserSubject(ownerUser))
|
|
|
|
_, err := api.Database.InsertUserSkill(authzCtx, tt.params)
|
|
require.True(t, database.IsCheckViolation(err, tt.constraint), "expected %s, got %v", tt.constraint, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUserSkillSchemaConstants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
var triggerDef string
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
ctx,
|
|
`SELECT pg_get_functiondef('enforce_skills_per_owner_limit'::regproc)`,
|
|
).Scan(&triggerDef))
|
|
assert.Contains(t, triggerDef, fmt.Sprintf("skill_limit constant int := %d;", skills.MaxPersonalSkillsPerUser))
|
|
|
|
constraints := map[database.CheckConstraint]string{
|
|
database.CheckSkillsNameSize: fmt.Sprintf("octet_length(name) <= %d", skills.MaxPersonalSkillNameBytes),
|
|
database.CheckSkillsNameFormat: "name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text",
|
|
database.CheckSkillsContentSize: fmt.Sprintf("octet_length(content) <= %d", skills.MaxPersonalSkillSizeBytes),
|
|
}
|
|
for constraint, expected := range constraints {
|
|
t.Run(string(constraint), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
var constraintDef string
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
ctx,
|
|
`SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1`,
|
|
constraint,
|
|
).Scan(&constraintDef))
|
|
assert.Contains(t, constraintDef, expected)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSkillAudit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range skillOwnerCases {
|
|
//nolint:paralleltest,tparallel // Subtests share one auditor and run sequentially.
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auditor := audit.NewMock()
|
|
owner, _ := tc.setup(t, &coderdtest.Options{Auditor: auditor})
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
auditor.ResetLogs()
|
|
|
|
genName := func(t *testing.T) string {
|
|
return strings.ToLower(strings.ReplaceAll(t.Name(), "/", "-"))
|
|
}
|
|
requireLog := func(t *testing.T, log database.AuditLog, action database.AuditAction, status int, skill codersdk.Skill) {
|
|
t.Helper()
|
|
assert.Equal(t, action, log.Action)
|
|
assert.EqualValues(t, status, log.StatusCode)
|
|
assert.Equal(t, owner.resourceType, log.ResourceType)
|
|
assert.Equal(t, owner.auditOrgID, log.OrganizationID)
|
|
assert.Equal(t, skill.ID, log.ResourceID)
|
|
assert.Equal(t, skill.Name, log.ResourceTarget)
|
|
}
|
|
|
|
t.Run("CreateEmitsLog", func(t *testing.T) {
|
|
auditor.ResetLogs()
|
|
skill, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown(genName(t), "Audit", "Body.")})
|
|
require.NoError(t, err)
|
|
|
|
logs := auditor.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
requireLog(t, logs[0], database.AuditActionCreate, http.StatusCreated, skill)
|
|
})
|
|
|
|
t.Run("UpdateEmitsLog", func(t *testing.T) {
|
|
name := genName(t)
|
|
skill, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown(name, "Initial", "Body.")})
|
|
require.NoError(t, err)
|
|
auditor.ResetLogs()
|
|
_, err = owner.update(ctx, name, codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown(name, "Updated", "Updated body.")),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
logs := auditor.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
requireLog(t, logs[0], database.AuditActionWrite, http.StatusOK, skill)
|
|
})
|
|
|
|
t.Run("DeleteEmitsLog", func(t *testing.T) {
|
|
name := genName(t)
|
|
skill, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown(name, "Delete", "Body.")})
|
|
require.NoError(t, err)
|
|
auditor.ResetLogs()
|
|
require.NoError(t, owner.delete(ctx, name))
|
|
|
|
logs := auditor.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
requireLog(t, logs[0], database.AuditActionDelete, http.StatusNoContent, skill)
|
|
})
|
|
|
|
t.Run("ReadsDoNotEmitLogs", func(t *testing.T) {
|
|
name := genName(t)
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown(name, "Read", "Body.")})
|
|
require.NoError(t, err)
|
|
auditor.ResetLogs()
|
|
|
|
_, err = owner.list(ctx)
|
|
require.NoError(t, err)
|
|
_, err = owner.get(ctx, name)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
|
|
t.Run("ValidationFailureDoesNotEmitLog", func(t *testing.T) {
|
|
auditor.ResetLogs()
|
|
_, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: userSkillMarkdown("bad-name", "Invalid", " \n")})
|
|
requireSDKErrorStatus(t, err, http.StatusBadRequest)
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
|
|
t.Run("MissingSkillFailuresDoNotEmitLogs", func(t *testing.T) {
|
|
auditor.ResetLogs()
|
|
_, err := owner.update(ctx, "missing-audit-skill", codersdk.UpdateSkillRequest{
|
|
Content: ptr.Ref(userSkillMarkdown("missing-audit-skill", "Missing", "Body.")),
|
|
})
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
err = owner.delete(ctx, "missing-audit-skill")
|
|
requireSDKErrorStatus(t, err, http.StatusNotFound)
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func userSkillMarkdown(name string, description string, body string) string {
|
|
return fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n\n%s\n", name, description, body)
|
|
}
|
|
|
|
func requireSDKErrorStatus(t *testing.T, err error, status int, msgAndArgs ...any) *codersdk.Error {
|
|
t.Helper()
|
|
require.Error(t, err, msgAndArgs...)
|
|
sdkErr := coderdtest.SDKError(t, err)
|
|
require.Equal(t, status, sdkErr.StatusCode(), msgAndArgs...)
|
|
return sdkErr
|
|
}
|