Files
coder/coderd/notifications_test.go
T
Susana Ferreira eec6c8c120 feat: support custom notifications (#19751)
## Description

Adds support for sending an ad‑hoc custom notification to the
authenticated user via API and CLI. This is useful for surfacing the
result of scripts or long‑running tasks. Notifications are delivered
through the configured method and the dashboard Inbox, respecting
existing preferences and delivery settings.

## Changes

* New notification template: “Custom Notification” with a label for a
custom title and a custom message.
* New API endpoint: `POST /api/v2/notifications/custom` to send a custom
notification to the requesting user.
* New API endpoint: `GET /notifications/templates/custom` to get custom
notification template.
* New CLI subcommand: `coder notifications custom <title> <message>` to
send a custom notification to the requesting user.
* Documentation updates: Add a “Custom notifications” section under
Administration > Monitoring > Notifications, including instructions on
sending custom notifications and examples of when to use them.

Closes: https://github.com/coder/coder/issues/19611
2025-09-11 15:08:57 +02:00

490 lines
16 KiB
Go

package coderd_test
import (
"net/http"
"slices"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/serpent"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func createOpts(t *testing.T) *coderdtest.Options {
t.Helper()
dt := coderdtest.DeploymentValues(t)
return &coderdtest.Options{
DeploymentValues: dt,
}
}
func TestUpdateNotificationsSettings(t *testing.T) {
t.Parallel()
t.Run("Permissions denied", func(t *testing.T) {
t.Parallel()
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// given
expected := codersdk.NotificationsSettings{
NotifierPaused: true,
}
ctx := testutil.Context(t, testutil.WaitShort)
// when
err := anotherClient.PutNotificationsSettings(ctx, expected)
// then
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
})
t.Run("Settings modified", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, client)
// given
expected := codersdk.NotificationsSettings{
NotifierPaused: true,
}
ctx := testutil.Context(t, testutil.WaitShort)
// when
err := client.PutNotificationsSettings(ctx, expected)
require.NoError(t, err)
// then
actual, err := client.GetNotificationsSettings(ctx)
require.NoError(t, err)
require.Equal(t, expected, actual)
})
t.Run("Settings not modified", func(t *testing.T) {
t.Parallel()
// Empty state: notifications Settings are undefined now (default).
client := coderdtest.New(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
// Change the state: pause notifications
err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{
NotifierPaused: true,
})
require.NoError(t, err)
// Verify the state: notifications are paused.
actual, err := client.GetNotificationsSettings(ctx)
require.NoError(t, err)
require.True(t, actual.NotifierPaused)
// Change the stage again: notifications are paused.
expected := actual
err = client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{
NotifierPaused: true,
})
require.NoError(t, err)
// Verify the state: notifications are still paused, and there is no error returned.
actual, err = client.GetNotificationsSettings(ctx)
require.NoError(t, err)
require.Equal(t, expected.NotifierPaused, actual.NotifierPaused)
})
}
func TestNotificationPreferences(t *testing.T) {
t.Parallel()
t.Run("Initial state", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member in its initial state.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: calling the API.
prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
// Then: no preferences will be returned.
require.Len(t, prefs, 0)
})
t.Run("Insufficient permissions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: 2 members.
_, member1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
member2Client, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to retrieve the preferences of another member.
_, err := member2Client.GetUserNotificationPreferences(ctx, member1.ID)
// Then: the API should reject the request.
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
// NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden.
// This is not ideal, and we should probably change this behavior.
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
})
t.Run("Admin may read any users' preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member.
_, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to retrieve the preferences of another member as an admin.
prefs, err := api.GetUserNotificationPreferences(ctx, member.ID)
// Then: the API should not reject the request.
require.NoError(t, err)
require.Len(t, prefs, 0)
})
t.Run("Admin may update any users' preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to modify and subsequently retrieve the preferences of another member as an admin.
prefs, err := api.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceMarkedForDeletion.String(): true,
},
})
// Then: the request should succeed and the user should be able to query their own preferences to see the same result.
require.NoError(t, err)
require.Len(t, prefs, 1)
memberPrefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
require.Len(t, memberPrefs, 1)
require.Equal(t, prefs[0].NotificationTemplateID, memberPrefs[0].NotificationTemplateID)
require.Equal(t, prefs[0].Disabled, memberPrefs[0].Disabled)
})
t.Run("Add preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member with no preferences.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
require.Len(t, prefs, 0)
// When: attempting to add new preferences.
template := notifications.TemplateWorkspaceDeleted
prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
template.String(): true,
},
})
// Then: the returning preferences should be set as expected.
require.NoError(t, err)
require.Len(t, prefs, 1)
require.Equal(t, prefs[0].NotificationTemplateID, template)
require.True(t, prefs[0].Disabled)
})
t.Run("Modify preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member with preferences.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
prefs, err := memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceDeleted.String(): true,
notifications.TemplateWorkspaceDormant.String(): true,
},
})
require.NoError(t, err)
require.Len(t, prefs, 2)
// When: attempting to modify their preferences.
prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceDeleted.String(): true,
notifications.TemplateWorkspaceDormant.String(): false, // <--- this one was changed
},
})
require.NoError(t, err)
require.Len(t, prefs, 2)
// Then: the modified preferences should be set as expected.
var found bool
for _, p := range prefs {
switch p.NotificationTemplateID {
case notifications.TemplateWorkspaceDormant:
found = true
require.False(t, p.Disabled)
case notifications.TemplateWorkspaceDeleted:
require.True(t, p.Disabled)
}
}
require.True(t, found, "dormant notification preference was not found")
})
}
func TestNotificationDispatchMethods(t *testing.T) {
t.Parallel()
defaultOpts := createOpts(t)
webhookOpts := createOpts(t)
webhookOpts.DeploymentValues.Notifications.Method = serpent.String(database.NotificationMethodWebhook)
tests := []struct {
name string
opts *coderdtest.Options
expectedDefault string
}{
{
name: "default",
opts: defaultOpts,
expectedDefault: string(database.NotificationMethodSmtp),
},
{
name: "non-default",
opts: webhookOpts,
expectedDefault: string(database.NotificationMethodWebhook),
},
}
var allMethods []string
for _, nm := range database.AllNotificationMethodValues() {
if nm == database.NotificationMethodInbox {
continue
}
allMethods = append(allMethods, string(nm))
}
slices.Sort(allMethods)
// nolint:paralleltest // Not since Go v1.22.
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api := coderdtest.New(t, tc.opts)
_ = coderdtest.CreateFirstUser(t, api)
resp, err := api.GetNotificationDispatchMethods(ctx)
require.NoError(t, err)
slices.Sort(resp.AvailableNotificationMethods)
require.EqualValues(t, resp.AvailableNotificationMethods, allMethods)
require.Equal(t, tc.expectedDefault, resp.DefaultNotificationMethod)
})
}
}
func TestNotificationTest(t *testing.T) {
t.Parallel()
t.Run("OwnerCanSendTestNotification", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
notifyEnq := &notificationstest.FakeEnqueuer{}
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: A user with owner permissions.
_ = coderdtest.CreateFirstUser(t, ownerClient)
// When: They attempt to send a test notification.
err := ownerClient.PostTestNotification(ctx)
require.NoError(t, err)
// Then: We expect a notification to have been sent.
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 1)
})
t.Run("MemberCannotSendTestNotification", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
notifyEnq := &notificationstest.FakeEnqueuer{}
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: A user without owner permissions.
ownerUser := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID)
// When: They attempt to send a test notification.
err := memberClient.PostTestNotification(ctx)
// Then: We expect a forbidden error with no notifications sent
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 0)
})
}
func TestCustomNotification(t *testing.T) {
t.Parallel()
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
notifyEnq := &notificationstest.FakeEnqueuer{}
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: A member user
ownerUser := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID)
// When: The member user attempts to send a custom notification with empty title and message
err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{
Content: &codersdk.CustomNotificationContent{
Title: "",
Message: "",
},
})
// Then: a bad request error is expected with no notifications sent
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
require.Equal(t, "Invalid request body", sdkError.Message)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 0)
})
t.Run("SystemUserNotAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
notifyEnq := &notificationstest.FakeEnqueuer{}
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: A system user (prebuilds system user)
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: database.PrebuildsSystemUserID,
LoginType: database.LoginTypeNone,
})
systemUserClient := codersdk.New(ownerClient.URL)
systemUserClient.SetSessionToken(token)
// When: The system user attempts to send a custom notification
err := systemUserClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{
Content: &codersdk.CustomNotificationContent{
Title: "Custom Title",
Message: "Custom Message",
},
})
// Then: a forbidden error is expected with no notifications sent
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
require.Equal(t, "Forbidden", sdkError.Message)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 0)
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
notifyEnq := &notificationstest.FakeEnqueuer{}
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: A member user
ownerUser := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, memberUser := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID)
// When: The member user attempts to send a custom notification
err := memberClient.PostCustomNotification(ctx, codersdk.CustomNotificationRequest{
Content: &codersdk.CustomNotificationContent{
Title: "Custom Title",
Message: "Custom Message",
},
})
require.NoError(t, err)
// Then: we expect a custom notification to be sent to the member user
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateCustomNotification))
require.Len(t, sent, 1)
require.Equal(t, memberUser.ID, sent[0].UserID)
require.Len(t, sent[0].Labels, 2)
require.Equal(t, "Custom Title", sent[0].Labels["custom_title"])
require.Equal(t, "Custom Message", sent[0].Labels["custom_message"])
require.Equal(t, memberUser.ID.String(), sent[0].CreatedBy)
})
}