feat: add tool to send a test notification (#16611)

Relates to https://github.com/coder/coder/issues/16463

Adds a CLI command, and API endpoint, to trigger a test notification for
administrators of a deployment.
This commit is contained in:
Danielle Maywood
2025-02-19 14:08:38 +01:00
committed by GitHub
parent 833ca53e51
commit d2419c89ac
20 changed files with 438 additions and 4 deletions

View File

@ -23,6 +23,10 @@ func (r *RootCmd) notifications() *serpent.Command {
Description: "Resume Coder notifications",
Command: "coder notifications resume",
},
Example{
Description: "Send a test notification. Administrators can use this to verify the notification target settings.",
Command: "coder notifications test",
},
),
Aliases: []string{"notification"},
Handler: func(inv *serpent.Invocation) error {
@ -31,6 +35,7 @@ func (r *RootCmd) notifications() *serpent.Command {
Children: []*serpent.Command{
r.pauseNotifications(),
r.resumeNotifications(),
r.testNotifications(),
},
}
return cmd
@ -83,3 +88,24 @@ func (r *RootCmd) resumeNotifications() *serpent.Command {
}
return cmd
}
func (r *RootCmd) testNotifications() *serpent.Command {
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "test",
Short: "Send a test notification",
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
if err := client.PostTestNotification(inv.Context()); err != nil {
return xerrors.Errorf("unable to post test notification: %w", err)
}
_, _ = fmt.Fprintln(inv.Stderr, "A test notification has been sent. If you don't receive the notification, check Coder's logs for any errors.")
return nil
},
}
return cmd
}

View File

@ -12,6 +12,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"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"
)
@ -109,3 +111,59 @@ func TestPauseNotifications_RegularUser(t *testing.T) {
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)
})
}

View File

@ -19,10 +19,17 @@ USAGE:
- Resume Coder notifications:
$ coder notifications resume
- Send a test notification. Administrators can use this to verify the
notification
target settings.:
$ coder notifications test
SUBCOMMANDS:
pause Pause notifications
resume Resume notifications
test Send a test notification
———
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder notifications test
Send a test notification
———
Run `coder --help` for a list of global options.

19
coderd/apidoc/docs.go generated
View File

@ -1787,6 +1787,25 @@ const docTemplate = `{
}
}
},
"/notifications/test": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Notifications"
],
"summary": "Send a test notification",
"operationId": "send-a-test-notification",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [

View File

@ -1554,6 +1554,23 @@
}
}
},
"/notifications/test": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Notifications"],
"summary": "Send a test notification",
"operationId": "send-a-test-notification",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [

View File

@ -1370,6 +1370,7 @@ func New(options *Options) *API {
r.Get("/system", api.systemNotificationTemplates)
})
r.Get("/dispatch-methods", api.notificationDispatchMethods)
r.Post("/test", api.postTestNotification)
})
r.Route("/tailnet", func(r chi.Router) {
r.Use(apiKeyMiddleware)

View File

@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'c425f63e-716a-4bf4-ae24-78348f706c3f';

View File

@ -0,0 +1,16 @@
INSERT INTO notification_templates
(id, name, title_template, body_template, "group", actions)
VALUES (
'c425f63e-716a-4bf4-ae24-78348f706c3f',
'Test Notification',
E'A test notification',
E'Hi {{.UserName}},\n\n'||
E'This is a test notification.',
'Notification Events',
'[
{
"label": "View notification settings",
"url": "{{base_url}}/deployment/notifications?tab=settings"
}
]'::jsonb
);

View File

@ -11,9 +11,12 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@ -163,6 +166,53 @@ func (api *API) notificationDispatchMethods(rw http.ResponseWriter, r *http.Requ
})
}
// @Summary Send a test notification
// @ID send-a-test-notification
// @Security CoderSessionToken
// @Tags Notifications
// @Success 200
// @Router /notifications/test [post]
func (api *API) postTestNotification(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
key = httpmw.APIKey(r)
)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
//nolint:gocritic // We need to be notifier to send the notification.
dbauthz.AsNotifier(ctx),
key.UserID,
notifications.TemplateTestNotification,
map[string]string{},
map[string]any{
// NOTE(DanielleMaywood):
// When notifications are enqueued, they are checked to be
// unique within a single day. This means that if we attempt
// to send two test notifications to the same user on
// the same day, the enqueuer will prevent us from sending
// a second one. We are injecting a timestamp to make the
// notifications appear different enough to circumvent this
// deduplication logic.
"timestamp": api.Clock.Now(),
},
"send-test-notification",
); err != nil {
api.Logger.Error(ctx, "send notification", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to send test notification",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, nil)
}
// @Summary Get user notification preferences
// @ID get-user-notification-preferences
// @Security CoderSessionToken

View File

@ -39,3 +39,8 @@ var (
TemplateWorkspaceBuildsFailedReport = uuid.MustParse("34a20db2-e9cc-4a93-b0e4-8569699d7a00")
)
// Notification-related events.
var (
TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f")
)

View File

@ -1125,6 +1125,16 @@ func TestNotificationTemplates_Golden(t *testing.T) {
},
},
},
{
name: "TemplateTestNotification",
id: notifications.TemplateTestNotification,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
},
},
}
// We must have a test case for every notification_template. This is enforced below:

View File

@ -0,0 +1,79 @@
From: system@coder.com
To: bobby@coder.com
Subject: A test notification
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
This is a test notification.
View notification settings: http://test.com/deployment/notifications?tab=3D=
settings
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>A test notification</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
A test notification
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>This is a test notification.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/deployment/notifications?tab=3Dsettings"=
style=3D"display: inline-block; padding: 13px 24px; background-color: #020=
617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4=
px;">
View notification settings
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Dc42=
5f63e-716a-4bf4-ae24-78348f706c3f" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--

View File

@ -0,0 +1,25 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.1",
"notification_name": "Test Notification",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View notification settings",
"url": "http://test.com/deployment/notifications?tab=settings"
}
],
"labels": {},
"data": null
},
"title": "A test notification",
"title_markdown": "A test notification",
"body": "Hi Bobby,\n\nThis is a test notification.",
"body_markdown": "Hi Bobby,\n\nThis is a test notification."
}

View File

@ -12,6 +12,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"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"
)
@ -317,3 +318,58 @@ func TestNotificationDispatchMethods(t *testing.T) {
})
}
}
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)
})
}

View File

@ -193,6 +193,20 @@ func (c *Client) GetNotificationDispatchMethods(ctx context.Context) (Notificati
return resp, nil
}
func (c *Client) PostTestNotification(ctx context.Context) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/notifications/test", nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ReadBodyAsError(res)
}
return nil
}
type UpdateNotificationTemplateMethod struct {
Method string `json:"method,omitempty" example:"webhook"`
}

View File

@ -1038,6 +1038,11 @@
"description": "Resume notifications",
"path": "reference/cli/notifications_resume.md"
},
{
"title": "notifications test",
"description": "Send a test notification",
"path": "reference/cli/notifications_test.md"
},
{
"title": "open",
"description": "Open a workspace",

View File

@ -182,6 +182,26 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Send a test notification
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/notifications/test \
-H 'Coder-Session-Token: API_KEY'
```
`POST /notifications/test`
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get user notification preferences
### Code samples

View File

@ -26,11 +26,17 @@ server or Webhook not responding).:
- Resume Coder notifications:
$ coder notifications resume
- Send a test notification. Administrators can use this to verify the notification
target settings.:
$ coder notifications test
```
## Subcommands
| Name | Purpose |
|--------------------------------------------------|----------------------|
| [<code>pause</code>](./notifications_pause.md) | Pause notifications |
| [<code>resume</code>](./notifications_resume.md) | Resume notifications |
| Name | Purpose |
|--------------------------------------------------|--------------------------|
| [<code>pause</code>](./notifications_pause.md) | Pause notifications |
| [<code>resume</code>](./notifications_resume.md) | Resume notifications |
| [<code>test</code>](./notifications_test.md) | Send a test notification |

10
docs/reference/cli/notifications_test.md generated Normal file
View File

@ -0,0 +1,10 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# notifications test
Send a test notification
## Usage
```console
coder notifications test
```