diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index daa55fb1bb..5b23e7b288 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3345,6 +3345,10 @@ CREATE INDEX tasks_organization_id_idx ON tasks USING btree (organization_id); CREATE INDEX tasks_owner_id_idx ON tasks USING btree (owner_id); +CREATE UNIQUE INDEX tasks_owner_id_name_unique_idx ON tasks USING btree (owner_id, lower(name)) WHERE (deleted_at IS NULL); + +COMMENT ON INDEX tasks_owner_id_name_unique_idx IS 'Index to ensure uniqueness for task owner/name'; + CREATE INDEX tasks_workspace_id_idx ON tasks USING btree (workspace_id); CREATE INDEX template_usage_stats_start_time_idx ON template_usage_stats USING btree (start_time DESC); diff --git a/coderd/database/migrations/000380_task_name_unique.down.sql b/coderd/database/migrations/000380_task_name_unique.down.sql new file mode 100644 index 0000000000..b15f332555 --- /dev/null +++ b/coderd/database/migrations/000380_task_name_unique.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS tasks_owner_id_name_unique_idx; diff --git a/coderd/database/migrations/000380_task_name_unique.up.sql b/coderd/database/migrations/000380_task_name_unique.up.sql new file mode 100644 index 0000000000..13ccf0b2d3 --- /dev/null +++ b/coderd/database/migrations/000380_task_name_unique.up.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS tasks_owner_id_name_unique_idx ON tasks (owner_id, LOWER(name)) WHERE deleted_at IS NULL; +COMMENT ON INDEX tasks_owner_id_name_unique_idx IS 'Index to ensure uniqueness for task owner/name'; diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 1409a079f5..01584a03ba 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7105,6 +7105,88 @@ func TestGetTaskByWorkspaceID(t *testing.T) { } } +func TestTaskNameUniqueness(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + org := dbgen.Organization(t, db, database.Organization{}) + user1 := dbgen.User(t, db, database.User{}) + user2 := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user1.ID, + }) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + OrganizationID: org.ID, + CreatedBy: user1.ID, + }) + + taskName := "my-task" + + // Create initial task for user1. + task1 := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user1.ID, + Name: taskName, + TemplateVersionID: tv.ID, + Prompt: "Test prompt", + }) + require.NotEqual(t, uuid.Nil, task1.ID) + + tests := []struct { + name string + ownerID uuid.UUID + taskName string + wantErr bool + }{ + { + name: "duplicate task name same user", + ownerID: user1.ID, + taskName: taskName, + wantErr: true, + }, + { + name: "duplicate task name different case same user", + ownerID: user1.ID, + taskName: "MY-TASK", + wantErr: true, + }, + { + name: "same task name different user", + ownerID: user2.ID, + taskName: taskName, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + task, err := db.InsertTask(ctx, database.InsertTaskParams{ + OrganizationID: org.ID, + OwnerID: tt.ownerID, + Name: tt.taskName, + TemplateVersionID: tv.ID, + TemplateParameters: json.RawMessage("{}"), + Prompt: "Test prompt", + CreatedAt: dbtime.Now(), + }) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, task.ID) + require.NotEqual(t, task1.ID, task.ID) + } + }) + } +} + func TestUsageEventsTrigger(t *testing.T) { t.Parallel() diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 58206e8ad2..03e2efa093 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -121,6 +121,7 @@ const ( UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); + UniqueTasksOwnerIDNameUniqueIndex UniqueConstraint = "tasks_owner_id_name_unique_idx" // CREATE UNIQUE INDEX tasks_owner_id_name_unique_idx ON tasks USING btree (owner_id, lower(name)) WHERE (deleted_at IS NULL); UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text);