Files
vikunja/pkg/models/label_test.go
5576c8d4af fix(labels): let bots use labels created by their owner (#3609)
Label with zero task attachments unreachable by anyone but creator, so
label seeded by human unusable by that human's own bot — every attach
403s forever until someone else attaches it once.

Refs #3592

`hasAccessToLabel` grants non-creators access only through existing
`label_tasks` row on readable task. Bot owners already inherit access to
labels their bots created (c9c2c58c1); this adds missing reverse
direction so both sides of bot/owner pair share label access.

Human-to-human access unchanged: another user's labels stay invisible
until they show up on a task you can read — the restriction
GHSA-hj5c-mhh2-g7jq introduced. Bots inherit read/attach only; renaming
and deleting the owner's labels still requires being the owner.

### Scope: does not close #3592

Deliberately `Refs`, not `Fixes`. The issue also reports the
human-teammate case — someone you shared a project with still cannot
attach a label that has never been used. That stays broken here, because
the fix the issue proposes for it (allow attach whenever the caller can
write the target task) would let any user attach any label id to a task
they own and read the title back, re-opening GHSA-hj5c-mhh2-g7jq in a
new shape. Whether labels should become project-scoped or explicitly
shareable is a design question worth its own issue — leaving #3592 open
to track it.

Note: the commit trailer still reads `Fixes #3592`, so adjust the
message on squash-merge if you want the issue left open.

### Operator note: widened bot/owner scope

A bot token can now enumerate every label its owner has created via `GET
/api/v2/labels`, including labels only used on projects the bot was
never given access to, and can attach one to a task in any project it
can write (which then makes that label visible to that project's
members). Read-only — rename and delete still require being the owner.
Worth knowing if you hand bot tokens to third-party integrations.

## How to verify

1. As a human user, create a label and do not attach it to any task:
`POST /api/v2/labels {"title":"seeded"}` → note the returned id `N`.
2. Create a bot owned by that user (`PUT /api/v1/user/bots`), issue an
API token owned by the bot with the `tasks_labels` create scope, and
give the bot access to a project.
3. As the bot, create a task in that project, then attach the label:
`POST /api/v2/tasks/{taskID}/labels {"label_id": N}`.
4. **Expected:** the request returns 201 and the label appears on the
task. `GET /api/v2/labels` as the bot also lists label `N`.
**Before this PR:** step 3 returned 403 on every attempt, and label `N`
was missing from the bot's label listing, until some other user attached
the label to a task first.
5. As a second, unrelated user (not the bot's owner), attach the same
label to a task you can write. **Expected:** still 403 — the label
remains private to its creator until it is visible through a shared
task.
6. As the bot, try to rename and delete label `N` (`PUT` and `DELETE
/api/v2/labels/N`). **Expected:** 403 on both.

---------

Co-authored-by: kolaente <k@knt.li>
2026-08-27 21:55:14 +02:00

814 lines
22 KiB
Go

// 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 models
import (
"reflect"
"runtime"
"testing"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"github.com/stretchr/testify/require"
"gopkg.in/d4l3k/messagediff.v1"
)
func TestLabel_ReadAll(t *testing.T) {
type fields struct {
ID int64
Title string
Description string
HexColor string
CreatedByID int64
CreatedBy *user.User
Created time.Time
Updated time.Time
CRUDable web.CRUDable
Permissions web.Permissions
}
type args struct {
search string
a web.Auth
page int
}
user1 := &user.User{
ID: 1,
Username: "user1",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
user2 := &user.User{
ID: 2,
Username: "user2",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
}
user6 := &user.User{
ID: 6,
Username: "user6",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
}
tests := []struct {
name string
fields fields
args args
wantLs []*LabelWithTaskID
wantErr bool
}{
{
name: "normal",
args: args{
a: &user.User{ID: 1},
},
wantLs: []*LabelWithTaskID{
{
Label: Label{
ID: 1,
Title: "Label #1",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
{
Label: Label{
ID: 2,
Title: "Label #2",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
{
Label: Label{
ID: 4,
Title: "Label #4 - visible via other task",
Created: testCreatedTime,
Updated: testUpdatedTime,
CreatedByID: 2,
CreatedBy: user2,
},
},
{
Label: Label{
ID: 7,
Title: "Label #7 - created by user 1, no task attachment",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
{
Label: Label{
ID: 8,
Title: "Label #8 - user 1 creator, only attached to inaccessible task",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
{
// Attached to task 25 in project 16, visible via the team 1
// share on the parent project 33.
Label: Label{
ID: 10,
Title: "Label #10 - attached in child project only",
CreatedByID: 6,
CreatedBy: user6,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
},
},
},
{
name: "invalid user",
args: args{
a: &user.User{ID: -1},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &Label{
ID: tt.fields.ID,
Title: tt.fields.Title,
Description: tt.fields.Description,
HexColor: tt.fields.HexColor,
CreatedByID: tt.fields.CreatedByID,
CreatedBy: tt.fields.CreatedBy,
Created: tt.fields.Created,
Updated: tt.fields.Updated,
CRUDable: tt.fields.CRUDable,
Permissions: tt.fields.Permissions,
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
gotLs, _, _, err := l.ReadAll(s, tt.args.a, tt.args.search, tt.args.page, 0)
if (err != nil) != tt.wantErr {
t.Errorf("Label.ReadAll() error = %v, wantErr %v", err, tt.wantErr)
return
}
got := gotLs.([]*LabelWithTaskID)
if diff, equal := messagediff.PrettyDiff(got, tt.wantLs); !equal {
t.Errorf("Label.ReadAll() = %v, want %v, diff: %v", gotLs, tt.wantLs, diff)
}
})
}
}
func TestLabel_ReadOne(t *testing.T) {
type fields struct {
ID int64
Title string
Description string
HexColor string
CreatedByID int64
CreatedBy *user.User
Created time.Time
Updated time.Time
CRUDable web.CRUDable
Permissions web.Permissions
}
user1 := &user.User{
ID: 1,
Username: "user1",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
tests := []struct {
name string
fields fields
want *Label
wantErr bool
errType func(error) bool
auth web.Auth
wantForbidden bool
assertMaxPermission bool
wantMaxPermission int
}{
{
name: "Get label #1",
fields: fields{
ID: 1,
},
want: &Label{
ID: 1,
Title: "Label #1",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 1},
assertMaxPermission: true,
wantMaxPermission: int(PermissionAdmin),
},
{
name: "Get nonexistant label",
fields: fields{
ID: 9999,
},
wantErr: true,
errType: IsErrLabelDoesNotExist,
wantForbidden: true,
auth: &user.User{ID: 1},
},
{
name: "no permissions",
fields: fields{
ID: 3,
},
wantForbidden: true,
auth: &user.User{ID: 1},
},
{
// Label 4 is owned by user 2; user 1 can read it via a shared task
// but is not the owner, so max permission is read.
name: "Get label #4 - other user",
fields: fields{
ID: 4,
},
want: &Label{
ID: 4,
Title: "Label #4 - visible via other task",
CreatedByID: 2,
CreatedBy: &user.User{
ID: 2,
Username: "user2",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 1},
assertMaxPermission: true,
wantMaxPermission: int(PermissionRead),
},
{
// PoC for GHSA-hj5c-mhh2-g7jq: label 6 is reachable only via task
// 34 in the private project 20, user 1 must not see it.
name: "PoC GHSA-hj5c-mhh2-g7jq: label 6 attached only to unreachable task must be forbidden",
fields: fields{
ID: 6,
},
wantForbidden: true,
auth: &user.User{ID: 1},
},
{
// Creator of an unattached label must still be able to read it.
name: "creator can read own label with no task attachment",
fields: fields{
ID: 7,
},
want: &Label{
ID: 7,
Title: "Label #7 - created by user 1, no task attachment",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 1},
assertMaxPermission: true,
wantMaxPermission: int(PermissionAdmin),
},
{
// Label 8's only label_tasks row points at inaccessible task 34, so
// access comes from the creator branch; as the owner, user 1's max
// permission is admin.
name: "creator can read own label only attached to inaccessible task",
fields: fields{
ID: 8,
},
want: &Label{
ID: 8,
Title: "Label #8 - user 1 creator, only attached to inaccessible task",
CreatedByID: 1,
CreatedBy: user1,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 1},
assertMaxPermission: true,
wantMaxPermission: int(PermissionAdmin),
},
{
// Non-creator must not be able to read an unattached label owned
// by someone else — label 3 in fixtures.
name: "non-creator cannot read label with no task attachment",
fields: fields{
ID: 3,
},
wantForbidden: true,
auth: &user.User{ID: 1},
},
{
// Label 10 is attached only to task 25 in project 16; user 1's
// access comes from the team 1 share on the parent project 33.
name: "label attached to task in child project readable via parent share",
fields: fields{
ID: 10,
},
want: &Label{
ID: 10,
Title: "Label #10 - attached in child project only",
CreatedByID: 6,
CreatedBy: &user.User{
ID: 6,
Username: "user6",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 1},
assertMaxPermission: true,
wantMaxPermission: int(PermissionRead),
},
{
// Label 9 was created by bot 23, whose owner is user 21. The
// bot owner inherits admin-level access.
name: "bot owner can read label created by their bot",
fields: fields{
ID: 9,
},
want: &Label{
ID: 9,
Title: "Label #9 - created by bot 23 owned by user 21",
CreatedByID: 23,
CreatedBy: &user.User{
ID: 23,
Name: "Owner A Assistant",
Username: "bot-owner-a-assistant",
Issuer: "local",
BotOwnerID: 21,
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 21},
assertMaxPermission: true,
wantMaxPermission: int(PermissionAdmin),
},
{
// User 22 owns a different bot and must not see another owner's
// bot's label.
name: "non-owner cannot read label created by someone else's bot",
fields: fields{
ID: 9,
},
wantForbidden: true,
auth: &user.User{ID: 22},
},
{
// Label 11 is unattached, so only the owner branch can grant this (#3592).
name: "bot can read never-used label created by its owner",
fields: fields{
ID: 11,
},
want: &Label{
ID: 11,
Title: "Label #11 - created by user 21, owner of bot 23, no task attachment",
CreatedByID: 21,
CreatedBy: &user.User{
ID: 21,
Username: "user_bot_owner_a",
Password: "$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6",
Issuer: "local",
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
},
Created: testCreatedTime,
Updated: testUpdatedTime,
},
auth: &user.User{ID: 23, BotOwnerID: 21},
assertMaxPermission: true,
wantMaxPermission: int(PermissionRead),
},
{
// Bot 24 belongs to user 22, so user 21's label stays out of reach.
name: "bot cannot read label created by a different bot owner",
fields: fields{
ID: 11,
},
wantForbidden: true,
auth: &user.User{ID: 24, BotOwnerID: 22},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
l := &Label{
ID: tt.fields.ID,
Title: tt.fields.Title,
Description: tt.fields.Description,
HexColor: tt.fields.HexColor,
CreatedByID: tt.fields.CreatedByID,
CreatedBy: tt.fields.CreatedBy,
Created: tt.fields.Created,
Updated: tt.fields.Updated,
CRUDable: tt.fields.CRUDable,
Permissions: tt.fields.Permissions,
}
s := db.NewSession()
defer s.Close()
allowed, maxPermission, _ := l.CanRead(s, tt.auth)
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanRead() forbidden, want %v", tt.wantForbidden)
}
if allowed && tt.wantForbidden {
t.Errorf("Label.CanRead() allowed, want forbidden")
}
if tt.assertMaxPermission && maxPermission != tt.wantMaxPermission {
t.Errorf("Label.CanRead() maxPermission = %d, want %d", maxPermission, tt.wantMaxPermission)
}
err := l.ReadOne(s, tt.auth)
if (err != nil) != tt.wantErr {
t.Errorf("Label.ReadOne() error = %v, wantErr %v", err, tt.wantErr)
}
if (err != nil) && tt.wantErr && !tt.errType(err) {
t.Errorf("Label.ReadOne() Wrong error type! Error = %v, want = %v", err, runtime.FuncForPC(reflect.ValueOf(tt.errType).Pointer()).Name())
}
if diff, equal := messagediff.PrettyDiff(l, tt.want); !equal && !tt.wantErr && !tt.wantForbidden {
t.Errorf("Label.ReadAll() = %v, want %v, diff: %v", l, tt.want, diff)
}
})
}
}
func TestLabel_Create(t *testing.T) {
type fields struct {
ID int64
Title string
Description string
HexColor string
CreatedByID int64
CreatedBy *user.User
Created time.Time
Updated time.Time
CRUDable web.CRUDable
Permissions web.Permissions
}
type args struct {
a web.Auth
}
tests := []struct {
name string
fields fields
args args
wantErr bool
wantForbidden bool
}{
{
name: "normal",
fields: fields{
Title: "Test #1",
Description: "Lorem Ipsum",
HexColor: "ffccff",
},
args: args{
a: &user.User{ID: 1},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &Label{
ID: tt.fields.ID,
Title: tt.fields.Title,
Description: tt.fields.Description,
HexColor: tt.fields.HexColor,
CreatedByID: tt.fields.CreatedByID,
CreatedBy: tt.fields.CreatedBy,
Created: tt.fields.Created,
Updated: tt.fields.Updated,
CRUDable: tt.fields.CRUDable,
Permissions: tt.fields.Permissions,
}
s := db.NewSession()
defer s.Close()
allowed, _ := l.CanCreate(s, tt.args.a)
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanCreate() forbidden, want %v", tt.wantForbidden)
}
if allowed && tt.wantForbidden {
t.Errorf("Label.CanCreate() allowed, want forbidden")
}
if err := l.Create(s, tt.args.a); (err != nil) != tt.wantErr {
t.Errorf("Label.Create() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
require.NoError(t, s.Commit())
db.AssertExists(t, "labels", map[string]interface{}{
"id": l.ID,
"title": l.Title,
"description": l.Description,
"hex_color": l.HexColor,
}, false)
}
})
}
}
func TestLabel_Update(t *testing.T) {
type fields struct {
ID int64
Title string
Description string
HexColor string
CreatedByID int64
CreatedBy *user.User
Created time.Time
Updated time.Time
CRUDable web.CRUDable
Permissions web.Permissions
}
tests := []struct {
name string
fields fields
wantErr bool
auth web.Auth
wantForbidden bool
}{
{
name: "normal",
fields: fields{
ID: 1,
Title: "new and better",
},
auth: &user.User{ID: 1},
},
{
name: "nonexisting",
fields: fields{
ID: 99999,
Title: "new and better",
},
auth: &user.User{ID: 1},
wantForbidden: true,
wantErr: true,
},
{
name: "no permissions",
fields: fields{
ID: 3,
Title: "new and better",
},
auth: &user.User{ID: 1},
wantForbidden: true,
},
{
name: "no permissions other creator but access",
fields: fields{
ID: 4,
Title: "new and better",
},
auth: &user.User{ID: 1},
wantForbidden: true,
},
{
// Label 9 was created by bot 23 (owned by user 21). The bot's
// owner inherits update permission.
name: "bot owner can update label created by their bot",
fields: fields{
ID: 9,
Title: "new and better",
},
auth: &user.User{ID: 21},
},
{
// User 22 owns a different bot and must not be able to update
// another owner's bot's label.
name: "non-owner cannot update label created by someone else's bot",
fields: fields{
ID: 9,
Title: "new and better",
},
auth: &user.User{ID: 22},
wantForbidden: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &Label{
ID: tt.fields.ID,
Title: tt.fields.Title,
Description: tt.fields.Description,
HexColor: tt.fields.HexColor,
CreatedByID: tt.fields.CreatedByID,
CreatedBy: tt.fields.CreatedBy,
Created: tt.fields.Created,
Updated: tt.fields.Updated,
CRUDable: tt.fields.CRUDable,
Permissions: tt.fields.Permissions,
}
s := db.NewSession()
defer s.Close()
allowed, _ := l.CanUpdate(s, tt.auth)
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanUpdate() forbidden, want %v", tt.wantForbidden)
}
if allowed && tt.wantForbidden {
t.Errorf("Label.CanUpdate() allowed, want forbidden")
}
if tt.wantForbidden {
return
}
if err := l.Update(s, tt.auth); (err != nil) != tt.wantErr {
t.Errorf("Label.Update() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
require.NoError(t, s.Commit())
db.AssertExists(t, "labels", map[string]interface{}{
"id": tt.fields.ID,
"title": tt.fields.Title,
}, false)
}
})
}
}
func TestLabel_Delete(t *testing.T) {
type fields struct {
ID int64
Title string
Description string
HexColor string
CreatedByID int64
CreatedBy *user.User
Created time.Time
Updated time.Time
CRUDable web.CRUDable
Permissions web.Permissions
}
tests := []struct {
name string
fields fields
wantErr bool
auth web.Auth
wantForbidden bool
}{
{
name: "normal",
fields: fields{
ID: 1,
},
auth: &user.User{ID: 1},
},
{
name: "nonexisting",
fields: fields{
ID: 99999,
},
auth: &user.User{ID: 1},
wantForbidden: true, // When the label does not exist, it is forbidden. We should fix this, but for everything.
},
{
name: "no permissions",
fields: fields{
ID: 3,
},
auth: &user.User{ID: 1},
wantForbidden: true,
},
{
name: "no permissions but visible",
fields: fields{
ID: 4,
},
auth: &user.User{ID: 1},
wantForbidden: true,
},
{
// Label 9 was created by bot 23 (owned by user 21). The bot's
// owner inherits delete permission.
name: "bot owner can delete label created by their bot",
fields: fields{
ID: 9,
},
auth: &user.User{ID: 21},
},
{
// User 22 owns a different bot and must not be able to delete
// another owner's bot's label.
name: "non-owner cannot delete label created by someone else's bot",
fields: fields{
ID: 9,
},
auth: &user.User{ID: 22},
wantForbidden: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &Label{
ID: tt.fields.ID,
Title: tt.fields.Title,
Description: tt.fields.Description,
HexColor: tt.fields.HexColor,
CreatedByID: tt.fields.CreatedByID,
CreatedBy: tt.fields.CreatedBy,
Created: tt.fields.Created,
Updated: tt.fields.Updated,
CRUDable: tt.fields.CRUDable,
Permissions: tt.fields.Permissions,
}
s := db.NewSession()
defer s.Close()
allowed, _ := l.CanDelete(s, tt.auth)
if !allowed && !tt.wantForbidden {
t.Errorf("Label.CanDelete() forbidden, want %v", tt.wantForbidden)
}
if allowed && tt.wantForbidden {
t.Errorf("Label.CanDelete() allowed, want forbidden")
}
if tt.wantForbidden {
return
}
if err := l.Delete(s, tt.auth); (err != nil) != tt.wantErr {
t.Errorf("Label.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
require.NoError(t, s.Commit())
db.AssertMissing(t, "labels", map[string]interface{}{
"id": l.ID,
})
}
})
}
}