Files
vikunja/pkg/models/subscription_test.go
T
kolaenteandkolaente e252954229 fix(notifications): don't notify subscribers who lost access to the entity
Subscriptions outlive access: nothing purges them when a project is
unshared, and access can change with no revocation event at all, so a
user who can no longer open a task kept receiving its comment bodies,
assignment details and deletion notices by mail and in the feed.

Filter subscribers by current read permission when the subscription is
fetched, so every listener is covered by one check. Rows are kept rather
than deleted - a subscription is user intent and resumes if access does.

GetSubscriptionsForDeletedTask keeps its own lookup because a
soft-deleted task cannot be resolved back to its project, but it now
reuses the same filter with the project id it already holds.
2026-07-29 07:33:35 +00:00

445 lines
11 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 (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestSubscriptionGetTypeFromString(t *testing.T) {
t.Run("project", func(t *testing.T) {
entityType := getEntityTypeFromString("project")
assert.Equal(t, SubscriptionEntityType(SubscriptionEntityProject), entityType)
})
t.Run("task", func(t *testing.T) {
entityType := getEntityTypeFromString("task")
assert.Equal(t, SubscriptionEntityType(SubscriptionEntityTask), entityType)
})
t.Run("invalid", func(t *testing.T) {
entityType := getEntityTypeFromString("someomejghsd")
assert.Equal(t, SubscriptionEntityType(SubscriptionEntityUnknown), entityType)
})
}
func TestSubscription_Create(t *testing.T) {
u := &user.User{ID: 1}
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "task",
EntityID: 1,
}
can, err := sb.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
err = sb.Create(s, u)
require.NoError(t, err)
require.NoError(t, s.Commit())
db.AssertExists(t, "subscriptions", map[string]interface{}{
"entity_type": 3,
"entity_id": 1,
"user_id": u.ID,
}, false)
})
t.Run("already exists", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "task",
EntityID: 2,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
err = sb.Create(s, u)
require.Error(t, err)
terr := &ErrSubscriptionAlreadyExists{}
assert.ErrorAs(t, err, &terr)
})
t.Run("forbidden for link shares", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
linkShare := &LinkSharing{}
sb := &Subscription{
Entity: "task",
EntityID: 1,
UserID: u.ID,
}
can, err := sb.CanCreate(s, linkShare)
require.Error(t, err)
assert.False(t, can)
})
t.Run("nonexisting project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "project",
EntityID: 99999999,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.Error(t, err)
assert.True(t, IsErrProjectDoesNotExist(err))
assert.False(t, can)
})
t.Run("noneixsting task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "task",
EntityID: 99999999,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
assert.False(t, can)
})
t.Run("no permissions to see project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "project",
EntityID: 20,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("no permissions to see task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "task",
EntityID: 14,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("existing subscription for (entity_id, entity_type, user_id) ", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sb := &Subscription{
Entity: "task",
EntityID: 2,
UserID: u.ID,
}
can, err := sb.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
err = sb.Create(s, u)
require.Error(t, err)
assert.True(t, IsErrSubscriptionAlreadyExists(err))
})
// TODO: Add tests to test triggering of notifications for subscribed things
}
func TestSubscription_Delete(t *testing.T) {
t.Run("normal", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u := &user.User{ID: 1}
sb := &Subscription{
Entity: "task",
EntityID: 2,
UserID: u.ID,
}
can, err := sb.CanDelete(s, u)
require.NoError(t, err)
assert.True(t, can)
err = sb.Delete(s, u)
require.NoError(t, err)
require.NoError(t, s.Commit())
db.AssertMissing(t, "subscriptions", map[string]interface{}{
"entity_type": 3,
"entity_id": 2,
"user_id": u.ID,
})
})
t.Run("forbidden for link shares", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
linkShare := &LinkSharing{}
sb := &Subscription{
Entity: "task",
EntityID: 1,
UserID: 1,
}
can, err := sb.CanDelete(s, linkShare)
require.Error(t, err)
assert.False(t, can)
})
t.Run("not owner of the subscription", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u := &user.User{ID: 2}
sb := &Subscription{
Entity: "task",
EntityID: 2,
UserID: u.ID,
}
can, err := sb.CanDelete(s, u)
require.NoError(t, err)
assert.False(t, can)
})
}
func TestSubscriptionGet(t *testing.T) {
u := &user.User{ID: 6}
t.Run("test each individually", func(t *testing.T) {
t.Run("project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sub, err := GetSubscriptionForUser(s, SubscriptionEntityProject, 12, u)
require.NoError(t, err)
assert.NotNil(t, sub)
assert.Equal(t, int64(3), sub.ID)
})
t.Run("task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 22, u)
require.NoError(t, err)
assert.NotNil(t, sub)
assert.Equal(t, int64(4), sub.ID)
})
})
t.Run("inherited", func(t *testing.T) {
t.Run("project from parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 25 belongs to project 12 where user 6 has subscribed to
sub, err := GetSubscriptionForUser(s, SubscriptionEntityProject, 25, u)
require.NoError(t, err)
assert.NotNil(t, sub)
assert.Equal(t, int64(12), sub.EntityID)
assert.Equal(t, int64(3), sub.ID)
})
t.Run("project from parent's parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 26 belongs to project 25 which belongs to project 12 where user 6 has subscribed to
sub, err := GetSubscriptionForUser(s, SubscriptionEntityProject, 26, u)
require.NoError(t, err)
assert.NotNil(t, sub)
assert.Equal(t, int64(12), sub.EntityID)
assert.Equal(t, int64(3), sub.ID)
})
t.Run("task from parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Task 39 belongs to project 25 which belongs to project 12 where the user has subscribed
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 39, u)
require.NoError(t, err)
assert.NotNil(t, sub)
// assert.Equal(t, int64(2), sub.ID) TODO
})
t.Run("task from project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Task 21 belongs to project 32 which the user has subscribed to
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 21, u)
require.NoError(t, err)
assert.NotNil(t, sub)
assert.Equal(t, int64(8), sub.ID)
})
})
t.Run("invalid type", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := GetSubscriptionForUser(s, 2342, 21, u)
require.Error(t, err)
assert.True(t, IsErrUnknownSubscriptionEntityType(err))
})
t.Run("double subscription should be returned once", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 18, u)
require.NoError(t, err)
assert.Equal(t, int64(9), sub.ID)
})
t.Run("soft-deleted task resolves no subscription", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Task 51 is soft-deleted; the raw CTE must not resolve its subscription (id 11)
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 51, &user.User{ID: 1})
require.NoError(t, err)
assert.Nil(t, sub)
})
}
func TestGetSubscriptionsForEntitySkipsUsersWithoutReadAccess(t *testing.T) {
const (
taskID int64 = 32
projectID int64 = 3
withAccess int64 = 2
lostAccess int64 = 6
)
subscribeBoth := func(t *testing.T, s *xorm.Session, entityType SubscriptionEntityType, entityID int64) {
for _, userID := range []int64{withAccess, lostAccess} {
_, err := s.Insert(&Subscription{
UserID: userID,
EntityType: entityType,
EntityID: entityID,
})
require.NoError(t, err)
}
}
subscriberIDs := func(subs []*SubscriptionWithUser) (ids []int64) {
for _, sub := range subs {
ids = append(ids, sub.UserID)
}
return ids
}
t.Run("task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
subscribeBoth(t, s, SubscriptionEntityTask, taskID)
subs, err := GetSubscriptionsForEntity(s, SubscriptionEntityTask, taskID)
require.NoError(t, err)
assert.Equal(t, []int64{withAccess}, subscriberIDs(subs))
})
t.Run("project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
subscribeBoth(t, s, SubscriptionEntityProject, projectID)
subs, err := GetSubscriptionsForEntity(s, SubscriptionEntityProject, projectID)
require.NoError(t, err)
assert.Equal(t, []int64{withAccess}, subscriberIDs(subs))
})
t.Run("user only lookup is not filtered", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
subscribeBoth(t, s, SubscriptionEntityTask, taskID)
subscribeBoth(t, s, SubscriptionEntityProject, projectID)
subs, err := GetSubscriptionsForEntitiesAndUser(s, SubscriptionEntityTask, []int64{taskID}, &user.User{ID: lostAccess})
require.NoError(t, err)
assert.Equal(t, []int64{lostAccess}, subscriberIDs(subs[taskID]))
subs, err = GetSubscriptionsForEntitiesAndUser(s, SubscriptionEntityProject, []int64{projectID}, &user.User{ID: lostAccess})
require.NoError(t, err)
assert.Equal(t, []int64{lostAccess}, subscriberIDs(subs[projectID]))
})
}
func TestSubscription_NoCrossUserProjectInheritance(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
user1 := &user.User{ID: 1}
user2 := &user.User{ID: 2}
sb := &Subscription{
Entity: "project",
EntityID: 3,
}
can, err := sb.CanCreate(s, user1)
require.NoError(t, err)
require.True(t, can)
require.NoError(t, sb.Create(s, user1))
sub, err := GetSubscriptionForUser(s, SubscriptionEntityTask, 32, user2)
require.NoError(t, err)
assert.Nil(t, sub)
}