Ctrl K
ComunidadGuías…

coderd/userskills_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)

			// The frontmatter key sets the default only on create; an
			// explicit model_invocable wins, and updates keep the stored value.
			manualOnlyContent := func(name, body string) string {
				return fmt.Sprintf("---\nname: %s\ndescription: Manual only\ndisable-model-invocation: true\n---\n\n%s\n", name, body)
			}
			flagged, err := owner.create(ctx, codersdk.CreateSkillRequest{Content: manualOnlyContent("flagged-skill", "Body.")})
			require.NoError(t, err)
			assert.False(t, flagged.ModelInvocable)
			explicit, err := owner.create(ctx, codersdk.CreateSkillRequest{
				Content:        manualOnlyContent("explicit-skill", "Body."),
				ModelInvocable: ptr.Ref(true),
			})
			require.NoError(t, err)
			assert.True(t, explicit.ModelInvocable)
			explicit, err = owner.update(ctx, "explicit-skill", codersdk.UpdateSkillRequest{
				Content: ptr.Ref(manualOnlyContent("explicit-skill", "Updated body.")),
			})
			require.NoError(t, err)
			assert.True(t, explicit.ModelInvocable)
		})
	}
}

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
}