vikunja/pkg/webtests/huma_task_assignee_test.go

298 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
"net/url"
"testing"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestHumaTaskAssignee re-proves the v1 assignee contract on /api/v2. Labels
// has a dedicated v1 webtest; assignees never did — the v1 coverage lived in
// the model and in archived_test.go — so this ports the full create/list/delete
// matrix 1:1 so the v2 HTTP surface independently proves it once v1's routes go.
//
// create/delete both require WRITE access to the task's project
// (canDoTaskAssingee → project.CanUpdate); list requires READ. The share-permission
// tasks 1526 map to the same projects the comment test's matrix uses, so the
// read-only-forbidden / write+admin-allowed split is identical to comment CREATE.
//
// Fixture topology (pkg/db/fixtures/task_assignees.yml, tasks.yml, projects.yml):
// - task 30 (project 1, owned by user1): assignees user1 (#1) and user2 (#2).
// - task 1 (project 1, owned by user1): no assignees; only user1 has project access.
// - tasks 1526: shared to user1 via every team/user/parent share kind.
// - task 34 (project 20, user13): user1 has no access at all.
func TestHumaTaskAssignee(t *testing.T) {
// task 30 belongs to project 1, owned by user1, and already has assignees.
onTask30 := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/tasks/30/assignees",
idParam: "user",
t: t,
}
require.NoError(t, onTask30.ensureEnv())
// onTaskAs reuses the one Echo instance (and its single fixture load) for a
// different task. v2 does not reload fixtures per request, so the subtests
// are ordered to avoid clobbering each other's rows.
onTaskAs := func(taskID string, u *user.User) *webHandlerTestV2 {
return &webHandlerTestV2{
user: u,
basePath: "/api/v2/tasks/" + taskID + "/assignees",
idParam: "user",
t: t,
e: onTask30.e,
}
}
// task 1 also belongs to project 1; used for clean creates (no fixture assignees).
onTask1 := onTaskAs("1", &testuser1)
// user6 has no access to project 1, so it can neither read nor write task 1.
asUser6 := onTaskAs("1", &testuser6)
t.Run("ReadAll", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := onTask30.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids := assigneeIDsFromReadAll(t, rec.Body.Bytes())
// task 30's assignees are exactly user1 and user2.
assert.ElementsMatch(t, []int64{1, 2}, ids,
"ReadAll must return exactly {1,2}; body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), `"username":"user1"`)
assert.Contains(t, rec.Body.String(), `"username":"user2"`)
})
t.Run("Empty", func(t *testing.T) {
// task 1 has no assignees; the list envelope is still well-formed.
rec, err := onTask1.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids := assigneeIDsFromReadAll(t, rec.Body.Bytes())
assert.Empty(t, ids)
})
t.Run("Search filter", func(t *testing.T) {
// ReadAll's search is an ILIKE on username; case-insensitive.
rec, err := onTask30.testReadAllWithUser(url.Values{"q": []string{"USER2"}}, nil)
require.NoError(t, err)
ids := assigneeIDsFromReadAll(t, rec.Body.Bytes())
assert.Equal(t, []int64{2}, ids, "search must narrow to user2; body: %s", rec.Body.String())
})
t.Run("Nonexisting task", func(t *testing.T) {
_, err := onTaskAs("99999", &testuser1).testReadAllWithUser(nil, nil)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
})
t.Run("Forbidden", func(t *testing.T) {
// user6 cannot read task 1.
_, err := asUser6.testReadAllWithUser(nil, nil)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
// Assign user1 to task 1: user1 has access to project 1 and may write it.
rec, err := onTask1.testCreateWithUser(nil, nil, `{"user_id":1}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"user_id":1`)
// created is server-set and serialized in snake_case.
assert.Contains(t, rec.Body.String(), `"created":`)
})
t.Run("Assignee without project access", func(t *testing.T) {
// user2 has no access to project 1, so it cannot be assigned to task 1.
_, err := onTask1.testCreateWithUser(nil, nil, `{"user_id":2}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, models.ErrCodeUserDoesNotHaveAccessToProject)
})
t.Run("Already assigned", func(t *testing.T) {
// task 30 already has user1 assigned (fixture #1).
_, err := onTask30.testCreateWithUser(nil, nil, `{"user_id":1}`)
require.Error(t, err)
assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, models.ErrCodeUserAlreadyAssigned)
})
t.Run("Nonexisting user", func(t *testing.T) {
_, err := onTask1.testCreateWithUser(nil, nil, `{"user_id":9999}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, user.ErrCodeUserDoesNotExist)
})
t.Run("Nonexisting task", func(t *testing.T) {
// The write check resolves the project from the task, so a missing
// task surfaces project-does-not-exist as a 404.
_, err := onTaskAs("99999", &testuser1).testCreateWithUser(nil, nil, `{"user_id":1}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
})
t.Run("Forbidden no access", func(t *testing.T) {
// user6 has no write access to task 1.
_, err := asUser6.testCreateWithUser(nil, nil, `{"user_id":6}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
// Permission matrix: CREATE requires write access to the task, so
// read-only shares are forbidden while write/admin shares are allowed.
// user1 has access to all these projects, so it can be the assignee.
// Mirrors the v1 archived/permission behaviour and the comment CREATE matrix.
t.Run("Permissions check", func(t *testing.T) {
// task 34 is owned by user13 — user1 has no access at all.
t.Run("Forbidden no access", func(t *testing.T) {
_, err := onTaskAs("34", &testuser1).testCreateWithUser(nil, nil, `{"user_id":1}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
// Read-only shares: create forbidden.
forbiddenCreate := map[string]string{
"Shared Via Team readonly": "15",
"Shared Via User readonly": "18",
"Shared Via Parent Project Team readonly": "21",
"Shared Via Parent Project User readonly": "24",
}
for name, taskID := range forbiddenCreate {
t.Run(name, func(t *testing.T) {
_, err := onTaskAs(taskID, &testuser1).testCreateWithUser(nil, nil, `{"user_id":1}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
}
// Write/admin shares: create allowed (8 positive cases).
allowedCreate := map[string]string{
"Shared Via Team write": "16",
"Shared Via Team admin": "17",
"Shared Via User write": "19",
"Shared Via User admin": "20",
"Shared Via Parent Project Team write": "22",
"Shared Via Parent Project Team admin": "23",
"Shared Via Parent Project User write": "25",
"Shared Via Parent Project User admin": "26",
}
for name, taskID := range allowedCreate {
t.Run(name, func(t *testing.T) {
rec, err := onTaskAs(taskID, &testuser1).testCreateWithUser(nil, nil, `{"user_id":1}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"user_id":1`)
})
}
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Nonexisting assignee on writable task", func(t *testing.T) {
// v1 parity: delete is permissive — removing a user who isn't assigned
// to a task the caller can write still succeeds with no content.
rec, err := onTask1.testDeleteWithUser(nil, map[string]string{"user": "9999"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
t.Run("Nonexisting task", func(t *testing.T) {
_, err := onTaskAs("99999", &testuser1).testDeleteWithUser(nil, map[string]string{"user": "2"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist)
})
t.Run("Forbidden no access", func(t *testing.T) {
// user6 has no write access to task 1.
_, err := asUser6.testDeleteWithUser(nil, map[string]string{"user": "2"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
// Permission matrix: DELETE requires write access to the task, same as
// create. Read-only shares 403 before touching the row; write/admin
// succeed (the row need not exist — v1 delete is permissive).
t.Run("Permissions check", func(t *testing.T) {
t.Run("Forbidden no access", func(t *testing.T) {
_, err := onTaskAs("34", &testuser1).testDeleteWithUser(nil, map[string]string{"user": "1"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
forbiddenDelete := map[string]string{
"Shared Via Team readonly": "15",
"Shared Via User readonly": "18",
"Shared Via Parent Project Team readonly": "21",
"Shared Via Parent Project User readonly": "24",
}
for name, taskID := range forbiddenDelete {
t.Run(name, func(t *testing.T) {
_, err := onTaskAs(taskID, &testuser1).testDeleteWithUser(nil, map[string]string{"user": "1"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
}
allowedDelete := map[string]string{
"Shared Via Team write": "16",
"Shared Via Team admin": "17",
"Shared Via User write": "19",
"Shared Via User admin": "20",
"Shared Via Parent Project Team write": "22",
"Shared Via Parent Project Team admin": "23",
"Shared Via Parent Project User write": "25",
"Shared Via Parent Project User admin": "26",
}
for name, taskID := range allowedDelete {
t.Run(name, func(t *testing.T) {
rec, err := onTaskAs(taskID, &testuser1).testDeleteWithUser(nil, map[string]string{"user": "1"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
})
}
})
t.Run("Normal", func(t *testing.T) {
// Run last: removes user2 from task 30, a fixture row the ReadAll
// cases above rely on.
rec, err := onTask30.testDeleteWithUser(nil, map[string]string{"user": "2"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
})
}
// assigneeIDsFromReadAll extracts the user ids from a v2 paginated assignee list
// so the visible set can be asserted exactly rather than via substring matching.
func assigneeIDsFromReadAll(t *testing.T, body []byte) []int64 {
t.Helper()
var resp struct {
Items []struct {
ID int64 `json:"id"`
} `json:"items"`
}
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
ids := make([]int64, 0, len(resp.Items))
for _, it := range resp.Items {
ids = append(ids, it.ID)
}
return ids
}