From 98554605247985d5d17388e2d95f9abecc83ec51 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 23 Oct 2025 19:45:18 +0300 Subject: [PATCH] feat(coderd): use new data model for task delete (#20334) Updates coder/internal#976 --- cli/exp_task_delete_test.go | 2 - coderd/aitasks.go | 124 ++++++++++------------ coderd/aitasks_test.go | 2 - coderd/apidoc/docs.go | 14 +-- coderd/apidoc/swagger.json | 14 +-- coderd/coderd.go | 12 ++- coderd/database/dbauthz/dbauthz.go | 13 +++ coderd/database/dbauthz/dbauthz_test.go | 10 ++ coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 33 ++++++ coderd/database/queries/tasks.sql | 9 ++ coderd/httpmw/taskparam.go | 57 ++++++++++ coderd/httpmw/taskparam_test.go | 120 +++++++++++++++++++++ docs/reference/api/experimental.md | 24 ++--- 16 files changed, 355 insertions(+), 102 deletions(-) create mode 100644 coderd/httpmw/taskparam.go create mode 100644 coderd/httpmw/taskparam_test.go diff --git a/cli/exp_task_delete_test.go b/cli/exp_task_delete_test.go index d41899067e..0b288c4ca3 100644 --- a/cli/exp_task_delete_test.go +++ b/cli/exp_task_delete_test.go @@ -22,8 +22,6 @@ import ( func TestExpTaskDelete(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - type testCounters struct { deleteCalls atomic.Int64 nameResolves atomic.Int64 diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 4fd49d73ee..2accd36712 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -593,9 +593,9 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 200 {object} codersdk.Task -// @Router /api/experimental/tasks/{user}/{id} [get] +// @Router /api/experimental/tasks/{user}/{task} [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. // taskGet is an experimental endpoint to fetch a single AI task by ID @@ -605,7 +605,7 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - idStr := chi.URLParam(r, "id") + idStr := chi.URLParam(r, "task") taskID, err := uuid.Parse(idStr) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -710,83 +710,71 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 202 "Task deletion initiated" -// @Router /api/experimental/tasks/{user}/{id} [delete] +// @Router /api/experimental/tasks/{user}/{task} [delete] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// taskDelete is an experimental endpoint to delete a task by ID (workspace ID). +// taskDelete is an experimental endpoint to delete a task by ID. // It creates a delete workspace build and returns 202 Accepted if the build was // created. func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + task := httpmw.TaskParam(r) - idStr := chi.URLParam(r, "id") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return + now := api.Clock.Now() + + if task.WorkspaceID.Valid { + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task workspace before deleting task.", + Detail: err.Error(), + }) + return + } + + // Construct a request to the workspace build creation handler to + // initiate deletion. + buildReq := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + Reason: "Deleted via tasks API", + } + + _, err = api.postWorkspaceBuildsInternal( + ctx, + apiKey, + workspace, + buildReq, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + if err != nil { + httperror.WriteWorkspaceBuildError(ctx, rw, err) + return + } } - // For now, taskID = workspaceID, once we have a task data model in - // the DB, we can change this lookup. - workspaceID := taskID - workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } + _, err := api.Database.DeleteTask(ctx, database.DeleteTaskParams{ + ID: task.ID, + DeletedAt: dbtime.Time(now), + }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace.", + Message: "Failed to delete task", Detail: err.Error(), }) return } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace resources.", - Detail: err.Error(), - }) - return - } - if len(data.builds) == 0 || len(data.templates) == 0 { - httpapi.ResourceNotFound(rw) - return - } - if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask { - httpapi.ResourceNotFound(rw) - return - } - - // Construct a request to the workspace build creation handler to - // initiate deletion. - buildReq := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionDelete, - Reason: "Deleted via tasks API", - } - - _, err = api.postWorkspaceBuildsInternal( - ctx, - apiKey, - workspace, - buildReq, - func(action policy.Action, object rbac.Objecter) bool { - return api.Authorize(r, action, object) - }, - audit.WorkspaceBuildBaggageFromRequest(r), - ) - if err != nil { - httperror.WriteWorkspaceBuildError(ctx, rw, err) - return - } - - // Delete build created successfully. + // Task deleted and delete build created successfully. rw.WriteHeader(http.StatusAccepted) } @@ -796,10 +784,10 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Param request body codersdk.TaskSendRequest true "Task input request" // @Success 204 "Input sent successfully" -// @Router /api/experimental/tasks/{user}/{id}/send [post] +// @Router /api/experimental/tasks/{user}/{task}/send [post] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. // taskSend submits task input to the tasks sidebar app by dialing the agent @@ -808,7 +796,7 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - idStr := chi.URLParam(r, "id") + idStr := chi.URLParam(r, "task") taskID, err := uuid.Parse(idStr) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -878,9 +866,9 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 200 {object} codersdk.TaskLogsResponse -// @Router /api/experimental/tasks/{user}/{id}/logs [get] +// @Router /api/experimental/tasks/{user}/{task}/logs [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. // taskLogs reads task output by dialing the agent directly over the tailnet. @@ -888,7 +876,7 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - idStr := chi.URLParam(r, "id") + idStr := chi.URLParam(r, "task") taskID, err := uuid.Parse(idStr) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index d0ff421c52..5623eb3076 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -343,8 +343,6 @@ func TestTasks(t *testing.T) { t.Run("Delete", func(t *testing.T) { t.Parallel() - t.Skip("TODO(mafredri): Remove, fixed down-stack!") - t.Run("OK", func(t *testing.T) { t.Parallel() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e7afae463b..d6ba1d081d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -229,7 +229,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}": { + "/api/experimental/tasks/{user}/{task}": { "get": { "security": [ { @@ -253,7 +253,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -290,7 +290,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -302,7 +302,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}/logs": { + "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ { @@ -326,7 +326,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -341,7 +341,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}/send": { + "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ { @@ -365,7 +365,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9c7c217b3d..bf363ba78b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -201,7 +201,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}": { + "/api/experimental/tasks/{user}/{task}": { "get": { "security": [ { @@ -223,7 +223,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -258,7 +258,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -270,7 +270,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}/logs": { + "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ { @@ -292,7 +292,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -307,7 +307,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}/send": { + "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ { @@ -329,7 +329,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true }, diff --git a/coderd/coderd.go b/coderd/coderd.go index fd1cdccff3..a1f94bfa6f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1032,11 +1032,15 @@ func New(options *Options) *API { r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) - r.Get("/{id}", api.taskGet) - r.Delete("/{id}", api.taskDelete) - r.Post("/{id}/send", api.taskSend) - r.Get("/{id}/logs", api.taskLogs) r.Post("/", api.tasksCreate) + + r.Route("/{task}", func(r chi.Router) { + r.Use(httpmw.ExtractTaskParam(options.Database)) + r.Get("/", api.taskGet) + r.Delete("/", api.taskDelete) + r.Post("/send", api.taskSend) + r.Get("/logs", api.taskLogs) + }) }) }) r.Route("/mcp", func(r chi.Router) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 110e63f928..52089e55c5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1798,6 +1798,19 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + task, err := q.db.GetTaskByID(ctx, arg.ID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionDelete, task.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + return q.db.DeleteTask(ctx, arg) +} + func (q *querier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { // First get the secret to check ownership secret, err := q.GetUserSecret(ctx, id) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 94970e4657..070da4f278 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2362,6 +2362,16 @@ func (s *MethodTestSuite) TestTasks() { dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() check.Args(task.ID).Asserts(task, policy.ActionRead).Returns(task) })) + s.Run("DeleteTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + arg := database.DeleteTaskParams{ + ID: task.ID, + DeletedAt: dbtime.Now(), + } + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().DeleteTask(gomock.Any(), arg).Return(database.TaskTable{}, nil).AnyTimes() + check.Args(arg).Asserts(task, policy.ActionDelete).Returns(database.TaskTable{}) + })) s.Run("InsertTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { tpl := testutil.Fake(s.T(), faker, database.Template{}) tv := testutil.Fake(s.T(), faker, database.TemplateVersion{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 8f5efaa853..a61d05cdef 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -474,6 +474,13 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.DeleteTask(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteTask").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteUserSecret(ctx, id) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index acaa2a8c5e..157883c6df 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -880,6 +880,21 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg) } +// DeleteTask mocks base method. +func (m *MockStore) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTask", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteTask indicates an expected call of DeleteTask. +func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg) +} + // DeleteUserSecret mocks base method. func (m *MockStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f625fc0b09..a1c6120780 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -120,6 +120,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteTask(ctx context.Context, arg DeleteTaskParams) (TaskTable, error) DeleteUserSecret(ctx context.Context, id uuid.UUID) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 77f3ae50d3..55bb5953d3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12581,6 +12581,39 @@ func (q *sqlQuerier) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetT return i, err } +const deleteTask = `-- name: DeleteTask :one +UPDATE tasks +SET + deleted_at = $1::timestamptz +WHERE + id = $2::uuid + AND deleted_at IS NULL +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +` + +type DeleteTaskParams struct { + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) DeleteTask(ctx context.Context, arg DeleteTaskParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, deleteTask, arg.DeletedAt, arg.ID) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const getTaskByID = `-- name: GetTaskByID :one SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, workspace_build_number, workspace_agent_id, workspace_app_id FROM tasks_with_status WHERE id = $1::uuid ` diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 5e466ea10f..9e83a9f87a 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -47,3 +47,12 @@ WHERE tws.deleted_at IS NULL AND CASE WHEN @owner_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = @owner_id::UUID ELSE TRUE END AND CASE WHEN @organization_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = @organization_id::UUID ELSE TRUE END ORDER BY tws.created_at DESC; + +-- name: DeleteTask :one +UPDATE tasks +SET + deleted_at = @deleted_at::timestamptz +WHERE + id = @id::uuid + AND deleted_at IS NULL +RETURNING *; diff --git a/coderd/httpmw/taskparam.go b/coderd/httpmw/taskparam.go new file mode 100644 index 0000000000..6ecc888b37 --- /dev/null +++ b/coderd/httpmw/taskparam.go @@ -0,0 +1,57 @@ +package httpmw + +import ( + "context" + "net/http" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw/loggermw" + "github.com/coder/coder/v2/codersdk" +) + +type taskParamContextKey struct{} + +// TaskParam returns the task from the ExtractTaskParam handler. +func TaskParam(r *http.Request) database.Task { + task, ok := r.Context().Value(taskParamContextKey{}).(database.Task) + if !ok { + panic("developer error: task param middleware not provided") + } + return task +} + +// ExtractTaskParam grabs a task from the "task" URL parameter by UUID. +func ExtractTaskParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + taskID, parsed := ParseUUIDParam(rw, r, "task") + if !parsed { + return + } + task, err := db.GetTaskByID(ctx, taskID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, taskParamContextKey{}, task) + + if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil { + rlogger.WithFields(slog.F("task_id", task.ID), slog.F("task_name", task.Name)) + } + + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/taskparam_test.go b/coderd/httpmw/taskparam_test.go new file mode 100644 index 0000000000..559ccc2a2d --- /dev/null +++ b/coderd/httpmw/taskparam_test.go @@ -0,0 +1,120 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +func TestTaskParam(t *testing.T) { + t.Parallel() + + setup := func(db database.Store) (*http.Request, database.User) { + user := dbgen.User(t, db, database.User{}) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, token) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", "me") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, user + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractTaskParam(db)) + rtr.Get("/", nil) + r, _ := setup(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractTaskParam(db)) + rtr.Get("/", nil) + r, _ := setup(db) + chi.RouteContext(r.Context()).URLParams.Add("task", uuid.NewString()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractTaskParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.TaskParam(r) + rw.WriteHeader(http.StatusOK) + }) + r, user := setup(db) + org := dbgen.Organization(t, db, database.Organization{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: tpl.ID, + Valid: true, + }, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + Name: "test-workspace", + OrganizationID: org.ID, + TemplateID: tpl.ID, + }) + task := dbgen.Task(t, db, database.TaskTable{ + Name: "test-task", + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateVersionID: tv.ID, + WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true}, + Prompt: "test prompt", + }) + chi.RouteContext(r.Context()).URLParams.Add("task", task.ID.String()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index edf6b729d8..e164e37aa5 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -84,19 +84,19 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id} \ +curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \ -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/experimental/tasks/{user}/{id}` +`GET /api/experimental/tasks/{user}/{task}` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Example responses @@ -116,18 +116,18 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id} \ +curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /api/experimental/tasks/{user}/{id}` +`DELETE /api/experimental/tasks/{user}/{task}` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Responses @@ -143,19 +143,19 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/logs \ +curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/logs \ -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/experimental/tasks/{user}/{id}/logs` +`GET /api/experimental/tasks/{user}/{task}/logs` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Example responses @@ -175,12 +175,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/send \ +curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/send \ -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /api/experimental/tasks/{user}/{id}/send` +`POST /api/experimental/tasks/{user}/{task}/send` > Body parameter @@ -195,7 +195,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/ | Name | In | Type | Required | Description | |--------|------|----------------------------------------------------------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | | `body` | body | [codersdk.TaskSendRequest](schemas.md#codersdktasksendrequest) | true | Task input request | ### Responses