Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/models/task_duplicate.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type TaskDuplicate struct {
TaskID int64 `json:"-" param:"projecttask"`

// The duplicated task
Task *Task `json:"duplicated_task,omitempty"`
Task *Task `json:"duplicated_task,omitempty" readOnly:"true" doc:"The newly created duplicate task, populated by the server in the response."`

web.Permissions `json:"-"`
web.CRUDable `json:"-"`
Expand Down
58 changes: 58 additions & 0 deletions pkg/routes/api/v2/task_duplicate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package apiv2

import (
"context"
"net/http"

"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"

"github.com/danielgtaylor/huma/v2"
)

// RegisterTaskDuplicateRoutes wires the task-duplicate action onto the Huma API.
// TaskDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate
// (its CanCreate enforces read-source + write-project); the only custom part is
// taking TaskID from the path rather than a request body.
func RegisterTaskDuplicateRoutes(api huma.API) {
tags := []string{"tasks"}

Register(api, huma.Operation{
OperationID: "tasks-duplicate",
Summary: "Duplicate a task",
Description: "Copies a task — including its labels, assignees, attachments and reminders — into the same project, and records a \"copied from\" relation back to the original. The authenticated user needs read access to the source task and write access to its project. Returns the newly created duplicate.",
Method: http.MethodPost,
Path: "/tasks/{projecttask}/duplicate",
Tags: tags,
}, tasksDuplicate)
}

func tasksDuplicate(ctx context.Context, in *struct {
TaskID int64 `path:"projecttask" doc:"The numeric id of the task to duplicate."`
}) (*singleBody[models.TaskDuplicate], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
td := &models.TaskDuplicate{TaskID: in.TaskID}
if err := handler.DoCreate(ctx, td, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.TaskDuplicate]{Body: td}, nil
}
1 change: 1 addition & 0 deletions pkg/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {

// Resource registrations.
apiv2.RegisterLabelRoutes(api)
apiv2.RegisterTaskDuplicateRoutes(api)

// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.
Expand Down
85 changes: 85 additions & 0 deletions pkg/webtests/huma_task_duplicate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package webtests

import (
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestTaskDuplicateV2 covers POST /tasks/{projecttask}/duplicate. It drives the
// Echo+Huma stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's
// buildURL only models base[/{id}] paths, not action sub-paths.
func TestTaskDuplicateV2(t *testing.T) {
t.Run("duplicates an accessible task", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)

// Task 2 lives in project 1, which testuser1 owns.
const sourceTaskID int64 = 2
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/2/duplicate", ``, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), `"duplicated_task"`)
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only asserts that the response contains "duplicated_task" and the source title, which could still pass if the handler accidentally returns the original task rather than a newly created copy. Consider also asserting something that proves a new task was created (e.g., the duplicated task has a different id than the source).

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in f5ad67f — the success test now parses the response and asserts the duplicated task id is non-zero and differs from the source, proving a new task was created.

assert.Contains(t, rec.Body.String(), `"title":"task #2 done"`)

// A returned original task would also pass the title check above; assert a new id.
var resp struct {
DuplicatedTask struct {
ID int64 `json:"id"`
} `json:"duplicated_task"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotZero(t, resp.DuplicatedTask.ID, "duplicated task should have an id")
assert.NotEqual(t, sourceTaskID, resp.DuplicatedTask.ID, "duplicated task must have a new id, not the source task's")
})

t.Run("nonexistent source task", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)

rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/99999/duplicate", `{}`, token, "")
// Missing source task yields ErrTaskDoesNotExist (404), not the 403 of the permission cases below.
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
})

t.Run("no read on source task is forbidden", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// testuser15 cannot read task 1 (project 1, owned by testuser1).
token := humaTokenFor(t, &testuser15)

rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/duplicate", `{}`, token, "")
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})

t.Run("read but no write on source project is forbidden", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// Task 32 lives in project 3, on which testuser1 has read-only access:
// CanRead passes, CanUpdate on the project fails, so CanCreate denies.
token := humaTokenFor(t, &testuser1)

rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/32/duplicate", `{}`, token, "")
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
}
Loading