From d2419c89acb505bc04a0a0467229663e66966db9 Mon Sep 17 00:00:00 2001
From: Danielle Maywood <danielle@themaywoods.com>
Date: Wed, 19 Feb 2025 14:08:38 +0100
Subject: [PATCH] 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.
---
 cli/notifications.go                          | 26 ++++++
 cli/notifications_test.go                     | 58 ++++++++++++++
 .../coder_notifications_--help.golden         |  7 ++
 .../coder_notifications_test_--help.golden    |  9 +++
 coderd/apidoc/docs.go                         | 19 +++++
 coderd/apidoc/swagger.json                    | 17 ++++
 coderd/coderd.go                              |  1 +
 .../000295_test_notification.down.sql         |  1 +
 .../000295_test_notification.up.sql           | 16 ++++
 coderd/notifications.go                       | 50 ++++++++++++
 coderd/notifications/events.go                |  5 ++
 coderd/notifications/notifications_test.go    | 10 +++
 .../smtp/TemplateTestNotification.html.golden | 79 +++++++++++++++++++
 .../TemplateTestNotification.json.golden      | 25 ++++++
 coderd/notifications_test.go                  | 56 +++++++++++++
 codersdk/notifications.go                     | 14 ++++
 docs/manifest.json                            |  5 ++
 docs/reference/api/notifications.md           | 20 +++++
 docs/reference/cli/notifications.md           | 14 +++-
 docs/reference/cli/notifications_test.md      | 10 +++
 20 files changed, 438 insertions(+), 4 deletions(-)
 create mode 100644 cli/testdata/coder_notifications_test_--help.golden
 create mode 100644 coderd/database/migrations/000295_test_notification.down.sql
 create mode 100644 coderd/database/migrations/000295_test_notification.up.sql
 create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateTestNotification.html.golden
 create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateTestNotification.json.golden
 create mode 100644 docs/reference/cli/notifications_test.md

diff --git a/cli/notifications.go b/cli/notifications.go
index 055a4bfa65..1769ef3aa1 100644
--- a/cli/notifications.go
+++ b/cli/notifications.go
@@ -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
+}
diff --git a/cli/notifications_test.go b/cli/notifications_test.go
index 9d775c6f58..5164657c6c 100644
--- a/cli/notifications_test.go
+++ b/cli/notifications_test.go
@@ -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)
+	})
+}
diff --git a/cli/testdata/coder_notifications_--help.golden b/cli/testdata/coder_notifications_--help.golden
index b54e98543d..ced45ca0da 100644
--- a/cli/testdata/coder_notifications_--help.golden
+++ b/cli/testdata/coder_notifications_--help.golden
@@ -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.
diff --git a/cli/testdata/coder_notifications_test_--help.golden b/cli/testdata/coder_notifications_test_--help.golden
new file mode 100644
index 0000000000..37c3402ba9
--- /dev/null
+++ b/cli/testdata/coder_notifications_test_--help.golden
@@ -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.
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 4068f1e022..089f98d0f1 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -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": [
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 6d63e3ed5b..c2e40ac88e 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -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": [
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 2b62d96b56..93aeb02adb 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -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)
diff --git a/coderd/database/migrations/000295_test_notification.down.sql b/coderd/database/migrations/000295_test_notification.down.sql
new file mode 100644
index 0000000000..f2e3558c8e
--- /dev/null
+++ b/coderd/database/migrations/000295_test_notification.down.sql
@@ -0,0 +1 @@
+DELETE FROM notification_templates WHERE id = 'c425f63e-716a-4bf4-ae24-78348f706c3f';
diff --git a/coderd/database/migrations/000295_test_notification.up.sql b/coderd/database/migrations/000295_test_notification.up.sql
new file mode 100644
index 0000000000..19c9e3655e
--- /dev/null
+++ b/coderd/database/migrations/000295_test_notification.up.sql
@@ -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
+);
diff --git a/coderd/notifications.go b/coderd/notifications.go
index 32f035a076..97cab982bd 100644
--- a/coderd/notifications.go
+++ b/coderd/notifications.go
@@ -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
diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go
index 5141f0f20c..3399da96cf 100644
--- a/coderd/notifications/events.go
+++ b/coderd/notifications/events.go
@@ -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")
+)
diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go
index 895fafff88..f6287993a3 100644
--- a/coderd/notifications/notifications_test.go
+++ b/coderd/notifications/notifications_test.go
@@ -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:
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTestNotification.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTestNotification.html.golden
new file mode 100644
index 0000000000..c7e5641c37
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTestNotification.html.golden
@@ -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--
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTestNotification.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTestNotification.json.golden
new file mode 100644
index 0000000000..a941faff13
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTestNotification.json.golden
@@ -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."
+}
\ No newline at end of file
diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go
index c4f0a551d4..2e8d851522 100644
--- a/coderd/notifications_test.go
+++ b/coderd/notifications_test.go
@@ -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)
+	})
+}
diff --git a/codersdk/notifications.go b/codersdk/notifications.go
index c1602c19f4..560499a672 100644
--- a/codersdk/notifications.go
+++ b/codersdk/notifications.go
@@ -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"`
 }
diff --git a/docs/manifest.json b/docs/manifest.json
index 3b49c2321c..2da08f84d6 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -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",
diff --git a/docs/reference/api/notifications.md b/docs/reference/api/notifications.md
index 0d9b07b3ff..b513786bfc 100644
--- a/docs/reference/api/notifications.md
+++ b/docs/reference/api/notifications.md
@@ -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
diff --git a/docs/reference/cli/notifications.md b/docs/reference/cli/notifications.md
index 169776876e..14642fd8dd 100644
--- a/docs/reference/cli/notifications.md
+++ b/docs/reference/cli/notifications.md
@@ -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 |
diff --git a/docs/reference/cli/notifications_test.md b/docs/reference/cli/notifications_test.md
new file mode 100644
index 0000000000..794c3e0d35
--- /dev/null
+++ b/docs/reference/cli/notifications_test.md
@@ -0,0 +1,10 @@
+<!-- DO NOT EDIT | GENERATED CONTENT -->
+# notifications test
+
+Send a test notification
+
+## Usage
+
+```console
+coder notifications test
+```