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 := ¬ificationstest.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 := ¬ificationstest.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>© 2024 Coder. All rights reserved - <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 := ¬ificationstest.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 := ¬ificationstest.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 +```