Files
coder/cli/notifications_test.go
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

270 lines
8.2 KiB
Go

package cli_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"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 TestNotifications(t *testing.T) {
t.Parallel()
tests := []struct {
name string
command string
expectPaused bool
}{
{
name: "PauseNotifications",
command: "pause",
expectPaused: true,
},
{
name: "ResumeNotifications",
command: "resume",
expectPaused: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, ownerClient)
// when
inv, root := clitest.New(t, "notifications", tt.command)
clitest.SetupConfig(t, ownerClient, root)
var buf bytes.Buffer
inv.Stdout = &buf
err := inv.Run()
require.NoError(t, err)
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
settingsJSON, err := db.GetNotificationsSettings(ctx)
require.NoError(t, err)
var settings codersdk.NotificationsSettings
err = json.Unmarshal([]byte(settingsJSON), &settings)
require.NoError(t, err)
require.Equal(t, tt.expectPaused, settings.NotifierPaused)
})
}
}
func TestPauseNotifications_RegularUser(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
owner := coderdtest.CreateFirstUser(t, ownerClient)
anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// when
inv, root := clitest.New(t, "notifications", "pause")
clitest.SetupConfig(t, anotherClient, root)
var buf bytes.Buffer
inv.Stdout = &buf
err := inv.Run()
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
assert.Contains(t, sdkError.Message, "Forbidden.")
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
settingsJSON, err := db.GetNotificationsSettings(ctx)
require.NoError(t, err)
var settings codersdk.NotificationsSettings
err = json.Unmarshal([]byte(settingsJSON), &settings)
require.NoError(t, err)
require.False(t, settings.NotifierPaused) // still running
}
func TestNotificationsTest(t *testing.T) {
t.Parallel()
t.Run("OwnerCanSendTestNotification", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
// Given: An owner user.
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
_ = coderdtest.CreateFirstUser(t, ownerClient)
// When: The owner user attempts to send the test notification.
inv, root := clitest.New(t, "notifications", "test")
clitest.SetupConfig(t, ownerClient, root)
// Then: we expect a notification to be sent.
err := inv.Run()
require.NoError(t, err)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 1)
})
t.Run("MemberCannotSendTestNotification", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
// Given: A member user.
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
ownerUser := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, ownerUser.OrganizationID)
// When: The member user attempts to send the test notification.
inv, root := clitest.New(t, "notifications", "test")
clitest.SetupConfig(t, memberClient, root)
// Then: we expect an error and no notifications to be sent.
err := inv.Run()
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTestNotification))
require.Len(t, sent, 0)
})
}
func TestCustomNotifications(t *testing.T) {
t.Parallel()
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
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
inv, root := clitest.New(t, "notifications", "custom", "", "")
clitest.SetupConfig(t, memberClient, root)
// Then: an error is expected with no notifications sent
err := inv.Run()
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()
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
inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message")
clitest.SetupConfig(t, systemUserClient, root)
// Then: an error is expected with no notifications sent
err := inv.Run()
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()
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
inv, root := clitest.New(t, "notifications", "custom", "Custom Title", "Custom Message")
clitest.SetupConfig(t, memberClient, root)
// Then: we expect a custom notification to be sent to the member user
err := inv.Run()
require.NoError(t, err)
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)
})
}