From 5dc57da6b42c143d4515ae3ebed55a3a6d649eff Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 13 Oct 2025 12:42:38 +0300 Subject: [PATCH] fix(coderd/database): ensure task name uniqueness (#20236) This change ensures task names are unique per user the same way we do for workspaces. This ensures we don't create tasks that are impossible to start due to another task being named the same creating a workspace name conflict. Updates coder/internal#948 Supersedes coder/coder#20212 --- coderd/database/dump.sql | 4 + .../000380_task_name_unique.down.sql | 1 + .../migrations/000380_task_name_unique.up.sql | 2 + coderd/database/querier_test.go | 82 +++++++++++++++++++ coderd/database/unique_constraint.go | 1 + 5 files changed, 90 insertions(+) create mode 100644 coderd/database/migrations/000380_task_name_unique.down.sql create mode 100644 coderd/database/migrations/000380_task_name_unique.up.sql 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);