Files
vikunja/pkg/notifications/notification_test.go
T
kolaenteandkolaente cd9033184b fix(notifications): require a persisted notification to declare its project
The capability interface carrying a notification's project was optional, and
ProjectIDOf defaulted a missing implementation to 0 — which means
account-scoped, which means always visible. So a new project-scoped type whose
author forgot the method would have leaked task titles, project names and
comment bodies to users with no access to the project, with no compile error
and no test failure.

Register now takes a factory returning PersistedNotification, which requires
the method. Registering is what makes a notification persist, so a stored row
that cannot be permission-checked no longer compiles. The three account-scoped
types say so by returning 0 explicitly instead of by omission.
2026-07-29 07:58:17 +00:00

274 lines
8.3 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 notifications
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/mail"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
type testNotification struct {
Test string
OtherValue int64
}
// ToMail returns the mail notification for testNotification
func (n *testNotification) ToMail(_ string) *Mail {
return NewMail().
Subject("Test Notification").
Line(n.Test)
}
// ToDB returns the testNotification notification in a format which can be saved in the db
func (n *testNotification) ToDB() interface{} {
data := make(map[string]interface{}, 2)
data["test"] = n.Test
data["other_value"] = n.OtherValue
return data
}
// Name returns the name of the notification
func (n *testNotification) Name() string {
return "test.notification"
}
type testNotifiable struct {
ShouldSendNotification bool
Language string
}
// RouteForMail routes a test notification for mail
func (t *testNotifiable) RouteForMail() (string, error) {
return "some@email.com", nil
}
// RouteForDB routes a test notification for db
func (t *testNotifiable) RouteForDB() int64 {
return 42
}
func (t *testNotifiable) ShouldNotify(_ ...*xorm.Session) (should bool, err error) {
return t.ShouldSendNotification, nil
}
func (t *testNotifiable) Lang() string {
return t.Language
}
// titlerNoSubjectNotification implements Titler and intentionally omits a
// Subject from ToMail so the fallback path is exercised.
type titlerNoSubjectNotification struct {
title string
}
func (n *titlerNoSubjectNotification) ToMail(_ string) *Mail {
return NewMail().Line("body")
}
func (n *titlerNoSubjectNotification) ToDB() interface{} { return nil }
func (n *titlerNoSubjectNotification) Name() string { return "test.titler.no.subject" }
func (n *titlerNoSubjectNotification) ToTitle(_ string) string { return n.title }
// titlerWithExplicitSubjectNotification implements Titler but also sets an
// explicit subject in ToMail; that explicit subject must win.
type titlerWithExplicitSubjectNotification struct {
title string
subject string
}
func (n *titlerWithExplicitSubjectNotification) ToMail(_ string) *Mail {
return NewMail().Subject(n.subject).Line("body")
}
func (n *titlerWithExplicitSubjectNotification) ToDB() interface{} { return nil }
func (n *titlerWithExplicitSubjectNotification) Name() string { return "test.titler.with.subject" }
func (n *titlerWithExplicitSubjectNotification) ToTitle(_ string) string { return n.title }
// noTitlerNotification is the control: no Titler, no Subject, fallback must
// leave subject empty without panicking.
type noTitlerNotification struct{}
func (n *noTitlerNotification) ToMail(_ string) *Mail { return NewMail().Line("body") }
func (n *noTitlerNotification) ToDB() interface{} { return nil }
func (n *noTitlerNotification) Name() string { return "test.no.titler" }
// titlerRegisteredNotification is used to exercise Register/Lookup.
type titlerRegisteredNotification struct {
Title string `json:"title"`
}
func (n *titlerRegisteredNotification) ToMail(_ string) *Mail { return NewMail().Line("body") }
func (n *titlerRegisteredNotification) ToDB() interface{} { return n }
func (n *titlerRegisteredNotification) Name() string { return "test.registry.titler" }
func (n *titlerRegisteredNotification) ToTitle(_ string) string { return n.Title }
func (n *titlerRegisteredNotification) ProjectID() int64 { return 0 }
func TestRegistry(t *testing.T) {
Register(func() PersistedNotification { return &titlerRegisteredNotification{} })
t.Run("known name returns fresh instance", func(t *testing.T) {
n, ok := Lookup("test.registry.titler")
require.True(t, ok)
require.NotNil(t, n)
_, ok = n.(*titlerRegisteredNotification)
assert.True(t, ok)
})
t.Run("unknown name returns false", func(t *testing.T) {
_, ok := Lookup("does.not.exist")
assert.False(t, ok)
})
}
func TestNotify(t *testing.T) {
t.Run("normal", func(t *testing.T) {
s := db.NewSession()
_, err := s.Exec("delete from notifications")
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
mail.ResetSent()
tn := &testNotification{
Test: "somethingsomething",
OtherValue: 42,
}
tnf := &testNotifiable{
ShouldSendNotification: true,
Language: "en",
}
err = Notify(tnf, tn)
require.NoError(t, err)
vals := map[string]interface{}{
"notifiable_id": 42,
"notification": "'{\"other_value\":42,\"test\":\"somethingsomething\"}'",
}
if db.Type() == schemas.POSTGRES {
vals["notification::jsonb"] = vals["notification"].(string) + "::jsonb"
delete(vals, "notification")
}
if db.Type() == schemas.SQLITE {
vals["CAST(notification AS BLOB)"] = "CAST(" + vals["notification"].(string) + " AS BLOB)"
delete(vals, "notification")
}
db.AssertExists(t, "notifications", vals, true)
sent := mail.LastSent()
require.NotNil(t, sent)
assert.Equal(t, "Test Notification", sent.Subject)
})
t.Run("mail queued only after the session commits", func(t *testing.T) {
mail.ResetSent()
tn := &testNotification{Test: "deferred", OtherValue: 1}
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
s := db.NewSession()
defer s.Close()
require.NoError(t, Notify(tnf, tn, s))
assert.Nil(t, mail.LastSent(), "mail must not be queued before the transaction commits")
require.NoError(t, s.Commit())
sent := mail.LastSent()
require.NotNil(t, sent)
assert.Equal(t, "Test Notification", sent.Subject)
})
t.Run("no mail when the session rolls back", func(t *testing.T) {
mail.ResetSent()
tn := &testNotification{Test: "rolledback", OtherValue: 2}
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
s := db.NewSession()
defer s.Close()
require.NoError(t, Notify(tnf, tn, s))
require.NoError(t, s.Rollback())
assert.Nil(t, mail.LastSent())
})
t.Run("subject fallback uses ToTitle when ToMail omits Subject", func(t *testing.T) {
mail.ResetSent()
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
err := notifyMail(tnf, &titlerNoSubjectNotification{title: "From ToTitle"})
require.NoError(t, err)
sent := mail.LastSent()
require.NotNil(t, sent)
assert.Equal(t, "From ToTitle", sent.Subject)
})
t.Run("explicit Subject in ToMail wins over ToTitle", func(t *testing.T) {
mail.ResetSent()
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
err := notifyMail(tnf, &titlerWithExplicitSubjectNotification{title: "From ToTitle", subject: "Explicit"})
require.NoError(t, err)
sent := mail.LastSent()
require.NotNil(t, sent)
assert.Equal(t, "Explicit", sent.Subject)
})
t.Run("no fallback when notification does not implement Titler", func(t *testing.T) {
mail.ResetSent()
tnf := &testNotifiable{ShouldSendNotification: true, Language: "en"}
err := notifyMail(tnf, &noTitlerNotification{})
require.NoError(t, err)
sent := mail.LastSent()
require.NotNil(t, sent)
assert.Empty(t, sent.Subject)
})
t.Run("disabled notifiable", func(t *testing.T) {
s := db.NewSession()
_, err := s.Exec("delete from notifications")
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
tn := &testNotification{
Test: "somethingsomething",
OtherValue: 42,
}
tnf := &testNotifiable{
ShouldSendNotification: false,
Language: "en",
}
err = Notify(tnf, tn)
require.NoError(t, err)
db.AssertMissing(t, "notifications", map[string]interface{}{
"notifiable_id": 42,
})
})
}