From 24de97f2e9014d421aae762003d557c66ec24896 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:41:43 +0200 Subject: [PATCH 1/6] feat(api): notification channel client for /v1/notification-channels with golden responses Co-Authored-By: Claude Opus 5.5 --- internal/api/notifications.go | 207 ++++++++++++++++++ internal/api/notifications_test.go | 196 +++++++++++++++++ .../notification_channel_create_https.json | 1 + .../phase3/notification_channels_list.json | 1 + .../phase3/notification_deliveries.json | 1 + .../testdata/phase3/notification_rotate.json | 1 + .../phase3/notification_test_failed.json | 1 + internal/api/write.go | 3 + 8 files changed, 411 insertions(+) create mode 100644 internal/api/notifications.go create mode 100644 internal/api/notifications_test.go create mode 100644 internal/api/testdata/phase3/notification_channel_create_https.json create mode 100644 internal/api/testdata/phase3/notification_channels_list.json create mode 100644 internal/api/testdata/phase3/notification_deliveries.json create mode 100644 internal/api/testdata/phase3/notification_rotate.json create mode 100644 internal/api/testdata/phase3/notification_test_failed.json diff --git a/internal/api/notifications.go b/internal/api/notifications.go new file mode 100644 index 0000000..273aec7 --- /dev/null +++ b/internal/api/notifications.go @@ -0,0 +1,207 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// Notification channels (helmcode/nan-devops +// AGENT-AUTOMATIONS-PHASE3-NOTIFICATIONS.md §4): where the platform tells a +// member that an automation run needs approval, failed or finished, that an +// automation was disabled or that a webhook trigger keeps failing. A +// channel's URL is a credential: it goes out in the body of a create or a +// URL replace and never comes back (url_hint is all the platform shows). +// An https channel's signing secret is answered once, by create and by +// rotate. + +const notificationChannelsPath = "/v1/notification-channels" + +// The kinds of channel. +const ( + NotificationKindSlack = "slack" + NotificationKindTeams = "teams" + NotificationKindDiscord = "discord" + NotificationKindHTTPS = "https" +) + +// NotificationEvents are the events a channel can subscribe to, in the +// order the platform documents them. +var NotificationEvents = []string{"approval.pending", "run.failed", "run.finished", "automation.disabled", "trigger.failing"} + +// DefaultNotificationEvents are what a new channel gets when no events are +// given (run.finished is opt-in). +var DefaultNotificationEvents = []string{"approval.pending", "run.failed", "automation.disabled", "trigger.failing"} + +// NotificationSubscription is one event of a channel, for every automation +// of the member (AutomationID nil) or for one. +type NotificationSubscription struct { + Event string `json:"event"` + AutomationID *string `json:"automation_id"` +} + +// NotificationSigning is the signing state of an https channel. +type NotificationSigning struct { + PreviousExpiresAt *string `json:"previous_expires_at"` +} + +// NotificationChannel is the channel object. SigningSecret is set only in +// the answer to the create of an https channel. +type NotificationChannel struct { + ID string `json:"id"` + Object string `json:"object,omitempty"` + Name string `json:"name"` + Kind string `json:"kind"` + URLHint string `json:"url_hint"` + Enabled bool `json:"enabled"` + DisabledReason *string `json:"disabled_reason"` + ConsecutiveFailures int `json:"consecutive_failures"` + LastDeliveryAt *string `json:"last_delivery_at"` + LastDeliveryStatus *string `json:"last_delivery_status"` + Subscriptions []NotificationSubscription `json:"subscriptions"` + Signing *NotificationSigning `json:"signing"` + UpdatedBy string `json:"updated_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + SigningSecret *string `json:"signing_secret,omitempty"` +} + +type NotificationChannelList struct { + Data []NotificationChannel `json:"data"` +} + +// NotificationDelivery is one attempt (or drop) the platform recorded. +type NotificationDelivery struct { + ID string `json:"id"` + Object string `json:"object,omitempty"` + Event string `json:"event"` + Items int `json:"items"` + RunID *string `json:"run_id"` + AutomationID *string `json:"automation_id"` + Attempt int `json:"attempt"` + Status string `json:"status"` + HTTPStatus *int `json:"http_status"` + Error *string `json:"error"` + CreatedAt string `json:"created_at"` +} + +type NotificationDeliveryList struct { + Data []NotificationDelivery `json:"data"` +} + +// NotificationTestResult is the answer to POST …/{id}/test. +type NotificationTestResult struct { + Delivered bool `json:"delivered"` + Status string `json:"status"` + HTTPStatus *int `json:"http_status"` + Error *string `json:"error"` +} + +// NotificationRotation is the answer to POST …/{id}/rotate-signing-secret. +type NotificationRotation struct { + SigningSecret string `json:"signing_secret"` + PreviousExpiresAt *string `json:"previous_expires_at"` +} + +// CreateNotificationChannelRequest is the body of a create. Its URL never +// prints: String and GoString redact it. +type CreateNotificationChannelRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + URL string `json:"url"` + Events *[]string `json:"events,omitempty"` + AutomationID *string `json:"automation_id,omitempty"` +} + +func (r CreateNotificationChannelRequest) String() string { + return fmt.Sprintf("CreateNotificationChannelRequest{Name:%q Kind:%q URL:[redacted]}", r.Name, r.Kind) +} +func (r CreateNotificationChannelRequest) GoString() string { return r.String() } + +// NotificationChannelPatch is the body of PATCH …/{id}: nil fields are +// left as they are. Its URL never prints. +type NotificationChannelPatch struct { + Name *string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + URL *string `json:"url,omitempty"` +} + +func (p NotificationChannelPatch) String() string { + name := "" + if p.Name != nil { + name = strconv.Quote(*p.Name) + } + enabled := "" + if p.Enabled != nil { + enabled = strconv.FormatBool(*p.Enabled) + } + return fmt.Sprintf("NotificationChannelPatch{Name:%s Enabled:%s URL:%s}", name, enabled, redactedIf(p.URL != nil)) +} +func (p NotificationChannelPatch) GoString() string { return p.String() } + +type notificationSubscriptionsBody struct { + Subscriptions []NotificationSubscription `json:"subscriptions"` +} + +func channelPath(id string) string { return notificationChannelsPath + "/" + url.PathEscape(id) } + +// ListNotificationChannels is GET /v1/notification-channels. +func (c *RunsClient) ListNotificationChannels(ctx context.Context) (*NotificationChannelList, error) { + list := &NotificationChannelList{} + return list, c.getJSON(ctx, notificationChannelsPath, list) +} + +// GetNotificationChannel is GET /v1/notification-channels/{id}. +func (c *RunsClient) GetNotificationChannel(ctx context.Context, id string) (*NotificationChannel, error) { + ch := &NotificationChannel{} + return ch, c.getJSON(ctx, channelPath(id), ch) +} + +// NotificationDeliveries is GET …/{id}/deliveries?limit=N (newest first). +func (c *RunsClient) NotificationDeliveries(ctx context.Context, id string, limit int) (*NotificationDeliveryList, error) { + list := &NotificationDeliveryList{} + return list, c.getJSON(ctx, channelPath(id)+"/deliveries?limit="+strconv.Itoa(limit), list) +} + +// CreateNotificationChannel is POST /v1/notification-channels. Not +// idempotent: after a lost answer, list the channels before trying again. +func (c *RunsClient) CreateNotificationChannel(ctx context.Context, in CreateNotificationChannelRequest) (*NotificationChannel, error) { + ch := &NotificationChannel{} + return ch, c.sendJSON(ctx, http.MethodPost, notificationChannelsPath, in, ch) +} + +// UpdateNotificationChannel is PATCH …/{id}. +func (c *RunsClient) UpdateNotificationChannel(ctx context.Context, id string, p NotificationChannelPatch) (*NotificationChannel, error) { + ch := &NotificationChannel{} + return ch, c.sendJSON(ctx, http.MethodPatch, channelPath(id), p, ch) +} + +// DeleteNotificationChannel is DELETE …/{id}. +func (c *RunsClient) DeleteNotificationChannel(ctx context.Context, id string) error { + return c.sendJSON(ctx, http.MethodDelete, channelPath(id), nil, nil) +} + +// PutNotificationSubscriptions is PUT …/{id}/subscriptions: subs replaces +// the channel's whole set (empty removes every subscription). +func (c *RunsClient) PutNotificationSubscriptions(ctx context.Context, id string, subs []NotificationSubscription) (*NotificationChannel, error) { + if subs == nil { + subs = []NotificationSubscription{} + } + ch := &NotificationChannel{} + return ch, c.sendJSON(ctx, http.MethodPut, channelPath(id)+"/subscriptions", notificationSubscriptionsBody{Subscriptions: subs}, ch) +} + +// TestNotificationChannel is POST …/{id}/test: one test notification, sent +// now. +func (c *RunsClient) TestNotificationChannel(ctx context.Context, id string) (*NotificationTestResult, error) { + out := &NotificationTestResult{} + return out, c.sendJSON(ctx, http.MethodPost, channelPath(id)+"/test", nil, out) +} + +// RotateNotificationSigningSecret is POST …/{id}/rotate-signing-secret. +func (c *RunsClient) RotateNotificationSigningSecret(ctx context.Context, id string) (*NotificationRotation, error) { + out := &NotificationRotation{} + return out, c.sendJSON(ctx, http.MethodPost, channelPath(id)+"/rotate-signing-secret", nil, out) +} diff --git a/internal/api/notifications_test.go b/internal/api/notifications_test.go new file mode 100644 index 0000000..1267e15 --- /dev/null +++ b/internal/api/notifications_test.go @@ -0,0 +1,196 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The notification channel objects as cloud-api serialises them +// (helmcode/nan-cloud-api#349, internal/handlers/notification_channels.go): +// testdata/phase3/notification_*.json. +func TestGoldenNotificationChannels(t *testing.T) { + var list NotificationChannelList + golden3(t, "notification_channels_list.json", &list) + if len(list.Data) != 2 { + t.Fatalf("list = %+v", list) + } + slack, https := list.Data[0], list.Data[1] + if slack.Name != "team alerts" || slack.Kind != "slack" || slack.URLHint != "hooks.slack.com/…/abcd" || !slack.Enabled || + slack.DisabledReason != nil || slack.Signing != nil || len(slack.Subscriptions) != 4 || + slack.Subscriptions[1].Event != "run.failed" || slack.Subscriptions[1].AutomationID != nil || + *slack.LastDeliveryStatus != "delivered" || slack.UpdatedBy != "browser_session" { + t.Fatalf("slack = %+v", slack) + } + if https.Enabled || *https.DisabledReason != "failing" || https.ConsecutiveFailures != 20 || https.Signing == nil || + *https.Signing.PreviousExpiresAt != "2026-10-15T09:00:00Z" || + *https.Subscriptions[0].AutomationID != "a0000000-0000-4000-8000-000000000001" || https.SigningSecret != nil { + t.Fatalf("https = %+v", https) + } + + var created NotificationChannel + golden3(t, "notification_channel_create_https.json", &created) + if created.SigningSecret == nil || !strings.HasPrefix(*created.SigningSecret, "nan_nsec_") || created.Signing == nil || + created.Signing.PreviousExpiresAt != nil || created.Kind != "https" { + t.Fatalf("created = %+v", created) + } + + var ds NotificationDeliveryList + golden3(t, "notification_deliveries.json", &ds) + if len(ds.Data) != 3 || ds.Data[0].Event != "digest" || ds.Data[0].Items != 12 || *ds.Data[0].HTTPStatus != 404 || + *ds.Data[0].Error != "http_4xx" || ds.Data[2].HTTPStatus != nil || *ds.Data[2].RunID == "" || ds.Data[2].Attempt != 2 { + t.Fatalf("deliveries = %+v", ds.Data) + } + + var tr NotificationTestResult + golden3(t, "notification_test_failed.json", &tr) + if tr.Delivered || tr.Status != "failed" || *tr.HTTPStatus != 404 || *tr.Error != "http_4xx" { + t.Fatalf("test = %+v", tr) + } + + var rot NotificationRotation + golden3(t, "notification_rotate.json", &rot) + if !strings.HasPrefix(rot.SigningSecret, "nan_nsec_") || *rot.PreviousExpiresAt != "2026-10-15T12:00:00Z" { + t.Fatalf("rotate = %+v", rot) + } +} + +const notifURL = "https://hooks.slack.com/services/T000/B000/XXXXsecretpartXXXX" + +// A webhook URL is a credential: it goes in the request body and nowhere +// a %v, %+v or %#v could print it. +func TestNotificationRequestsRedactTheURL(t *testing.T) { + u := notifURL + name := "n" + for _, v := range []any{ + CreateNotificationChannelRequest{Name: "n", Kind: "slack", URL: notifURL}, + NotificationChannelPatch{Name: &name, URL: &u}, + } { + for _, f := range []string{"%v", "%+v", "%#v", "%s"} { + if s := fmt.Sprintf(f, v); strings.Contains(s, "secretpart") { + t.Errorf("%s prints the URL: %s", f, s) + } + } + } + b, _ := json.Marshal(CreateNotificationChannelRequest{Name: "n", Kind: "slack", URL: notifURL}) + if string(b) != `{"name":"n","kind":"slack","url":"`+notifURL+`"}` { + t.Errorf("create body %s", b) + } + none := []string{} + aut := "a1" + b, _ = json.Marshal(CreateNotificationChannelRequest{Name: "n", Kind: "https", URL: "u", Events: &none, AutomationID: &aut}) + if string(b) != `{"name":"n","kind":"https","url":"u","events":[],"automation_id":"a1"}` { + t.Errorf("create body %s", b) + } + on := true + b, _ = json.Marshal(NotificationChannelPatch{Enabled: &on}) + if string(b) != `{"enabled":true}` { + t.Errorf("patch body %s", b) + } + b, _ = json.Marshal(notificationSubscriptionsBody{Subscriptions: []NotificationSubscription{{Event: "run.failed"}}}) + if string(b) != `{"subscriptions":[{"event":"run.failed","automation_id":null}]}` { + t.Errorf("subscriptions body %s", b) + } + b, _ = json.Marshal(notificationSubscriptionsBody{Subscriptions: []NotificationSubscription{}}) + if string(b) != `{"subscriptions":[]}` { + t.Errorf("empty subscriptions body %s", b) + } +} + +// Every route of /v1/notification-channels, with the method and body the +// platform expects. +func TestNotificationChannelRoutes(t *testing.T) { + const id = "c0000000-0000-4000-8000-000000000001" + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + got = append(got, r.Method+" "+r.URL.RequestURI()+" "+string(body)) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodDelete: + w.WriteHeader(204) + case strings.HasSuffix(r.URL.Path, "/deliveries"): + _, _ = io.WriteString(w, `{"object":"list","data":[]}`) + case strings.HasSuffix(r.URL.Path, "/test"): + _, _ = io.WriteString(w, `{"delivered":true,"status":"delivered","http_status":200,"error":null}`) + case strings.HasSuffix(r.URL.Path, "/rotate-signing-secret"): + _, _ = io.WriteString(w, `{"signing_secret":"nan_nsec_x","previous_expires_at":"2026-10-15T12:00:00Z"}`) + case r.URL.Path == "/v1/notification-channels" && r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"object":"list","data":[]}`) + default: + _, _ = io.WriteString(w, `{"id":"`+id+`","kind":"slack","subscriptions":[]}`) + } + })) + defer srv.Close() + c := NewRunsClient("", "nan_pat_x").WithBaseURL(srv.URL) + ctx := context.Background() + on := false + steps := []func() error{ + func() error { _, err := c.ListNotificationChannels(ctx); return err }, + func() error { _, err := c.GetNotificationChannel(ctx, id); return err }, + func() error { _, err := c.NotificationDeliveries(ctx, id, 7); return err }, + func() error { + _, err := c.CreateNotificationChannel(ctx, CreateNotificationChannelRequest{Name: "a", Kind: "slack", URL: "u"}) + return err + }, + func() error { + _, err := c.UpdateNotificationChannel(ctx, id, NotificationChannelPatch{Enabled: &on}) + return err + }, + func() error { return c.DeleteNotificationChannel(ctx, id) }, + func() error { + _, err := c.PutNotificationSubscriptions(ctx, id, []NotificationSubscription{{Event: "run.failed"}}) + return err + }, + func() error { _, err := c.TestNotificationChannel(ctx, id); return err }, + func() error { _, err := c.RotateNotificationSigningSecret(ctx, id); return err }, + } + for i, s := range steps { + if err := s(); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } + p := "/v1/notification-channels" + want := []string{ + "GET " + p + " ", + "GET " + p + "/" + id + " ", + "GET " + p + "/" + id + "/deliveries?limit=7 ", + "POST " + p + ` {"name":"a","kind":"slack","url":"u"}`, + "PATCH " + p + "/" + id + ` {"enabled":false}`, + "DELETE " + p + "/" + id + " ", + "PUT " + p + "/" + id + `/subscriptions {"subscriptions":[{"event":"run.failed","automation_id":null}]}`, + "POST " + p + "/" + id + "/test ", + "POST " + p + "/" + id + "/rotate-signing-secret ", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("requests:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// The portal's flat error and the /v1 envelope both carry code and param. +func TestNotificationErrorsCarryCodeAndParam(t *testing.T) { + for _, body := range []string{ + `{"error":{"message":"the URL is not valid","type":"invalid_request_error","param":"url","code":"invalid_url"}}`, + `{"error":"invalid_url","message":"the URL is not valid","param":"url"}`, + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + _, _ = io.WriteString(w, body) + })) + c := NewRunsClient("", "nan_pat_x").WithBaseURL(srv.URL) + _, err := c.CreateNotificationChannel(context.Background(), CreateNotificationChannelRequest{Name: "a", Kind: "slack", URL: notifURL}) + srv.Close() + apiErr, ok := err.(*APIError) + if !ok || apiErr.Code != "invalid_url" || apiErr.Param != "url" || apiErr.Status != 400 { + t.Fatalf("%T %+v", err, err) + } + if strings.Contains(err.Error(), "secretpart") { + t.Error("the error carries the URL") + } + } +} diff --git a/internal/api/testdata/phase3/notification_channel_create_https.json b/internal/api/testdata/phase3/notification_channel_create_https.json new file mode 100644 index 0000000..2ade091 --- /dev/null +++ b/internal/api/testdata/phase3/notification_channel_create_https.json @@ -0,0 +1 @@ +{"consecutive_failures":0,"created_at":"2026-10-14T12:00:00Z","disabled_reason":null,"enabled":true,"id":"c0000000-0000-4000-8000-000000000003","kind":"https","last_delivery_at":null,"last_delivery_status":null,"name":"my receiver","object":"notification_channel","signing":{"previous_expires_at":null},"signing_secret":"nan_nsec_SHOWNONCEshownonceSHOWNONCEshownonce123","subscriptions":[{"automation_id":null,"event":"approval.pending"},{"automation_id":null,"event":"run.failed"},{"automation_id":null,"event":"automation.disabled"},{"automation_id":null,"event":"trigger.failing"}],"updated_at":"2026-10-14T12:00:00Z","updated_by":"cli_session","url_hint":"hooks.example.com/…/x9Yz"} diff --git a/internal/api/testdata/phase3/notification_channels_list.json b/internal/api/testdata/phase3/notification_channels_list.json new file mode 100644 index 0000000..dd87f1c --- /dev/null +++ b/internal/api/testdata/phase3/notification_channels_list.json @@ -0,0 +1 @@ +{"object":"list","data":[{"id":"c0000000-0000-4000-8000-000000000001","object":"notification_channel","name":"team alerts","kind":"slack","url_hint":"hooks.slack.com/…/abcd","enabled":true,"disabled_reason":null,"consecutive_failures":0,"last_delivery_at":"2026-10-14T10:00:05Z","last_delivery_status":"delivered","subscriptions":[{"event":"approval.pending","automation_id":null},{"event":"run.failed","automation_id":null},{"event":"automation.disabled","automation_id":null},{"event":"trigger.failing","automation_id":null}],"signing":null,"updated_by":"browser_session","created_at":"2026-10-14T09:00:00Z","updated_at":"2026-10-14T09:00:00Z"},{"id":"c0000000-0000-4000-8000-000000000002","object":"notification_channel","name":"ci receiver","kind":"https","url_hint":"hooks.example.com","enabled":false,"disabled_reason":"failing","consecutive_failures":20,"last_delivery_at":"2026-10-14T11:30:00Z","last_delivery_status":"failed","subscriptions":[{"event":"run.finished","automation_id":"a0000000-0000-4000-8000-000000000001"}],"signing":{"previous_expires_at":"2026-10-15T09:00:00Z"},"updated_by":"cli_session","created_at":"2026-10-13T09:00:00Z","updated_at":"2026-10-14T09:00:00Z"}]} diff --git a/internal/api/testdata/phase3/notification_deliveries.json b/internal/api/testdata/phase3/notification_deliveries.json new file mode 100644 index 0000000..dfb3ae4 --- /dev/null +++ b/internal/api/testdata/phase3/notification_deliveries.json @@ -0,0 +1 @@ +{"object":"list","data":[{"id":"d0000000-0000-4000-8000-000000000003","object":"notification_delivery","event":"digest","items":12,"run_id":null,"automation_id":null,"attempt":1,"status":"failed","http_status":404,"error":"http_4xx","created_at":"2026-10-14T11:30:00Z"},{"id":"d0000000-0000-4000-8000-000000000002","object":"notification_delivery","event":"test","items":0,"run_id":null,"automation_id":null,"attempt":1,"status":"delivered","http_status":200,"error":null,"created_at":"2026-10-14T10:05:00Z"},{"id":"d0000000-0000-4000-8000-000000000001","object":"notification_delivery","event":"run.failed","items":1,"run_id":"6f1c2a9b-0000-4000-8000-000000000001","automation_id":"a0000000-0000-4000-8000-000000000001","attempt":2,"status":"retrying","http_status":null,"error":"timeout","created_at":"2026-10-14T10:00:00Z"}]} diff --git a/internal/api/testdata/phase3/notification_rotate.json b/internal/api/testdata/phase3/notification_rotate.json new file mode 100644 index 0000000..d6fd652 --- /dev/null +++ b/internal/api/testdata/phase3/notification_rotate.json @@ -0,0 +1 @@ +{"previous_expires_at":"2026-10-15T12:00:00Z","signing_secret":"nan_nsec_ROTATEDrotatedROTATEDrotatedROTATED12345"} diff --git a/internal/api/testdata/phase3/notification_test_failed.json b/internal/api/testdata/phase3/notification_test_failed.json new file mode 100644 index 0000000..55817cd --- /dev/null +++ b/internal/api/testdata/phase3/notification_test_failed.json @@ -0,0 +1 @@ +{"delivered":false,"error":"http_4xx","http_status":404,"status":"failed"} diff --git a/internal/api/write.go b/internal/api/write.go index 7c07db2..a13a259 100644 --- a/internal/api/write.go +++ b/internal/api/write.go @@ -19,6 +19,9 @@ type Features struct { WriteAPI bool `json:"write_api"` Secrets bool `json:"secrets"` Automations bool `json:"automations"` + // Notifications is features.notifications: notification channels are + // available to the member. + Notifications bool `json:"notifications"` } // Features is GET /api/auth/me, read for its feature flags. From 2f61333aabee182432f8412c463f733141eaa2d6 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:47:37 +0200 Subject: [PATCH 2/6] feat(notifications): nan notifications ls, show, add, edit, rm, test, rotate, deliveries and subscriptions Webhook URLs only from --url-file or --url-stdin (never an argument), the https signing secret printed once, exit 64 with a portal hint when the account has no notifications, httptest tests with cloud-api's golden objects, and every new --json command held to the stdout contract. Co-Authored-By: Claude Opus 5.5 --- cmd/json_output_test.go | 33 + cmd/notifications.go | 1212 +++++++++++++++++ cmd/notifications_test.go | 699 ++++++++++ cmd/testdata/notifications/add_https.golden | 2 + cmd/testdata/notifications/add_slack.golden | 1 + cmd/testdata/notifications/deliveries.golden | 4 + cmd/testdata/notifications/ls.golden | 3 + cmd/testdata/notifications/rotate.golden | 1 + .../notifications/show_failing.golden | 14 + cmd/testdata/notifications/show_slack.golden | 15 + .../notifications/subscriptions.golden | 2 + cmd/writeapi_test.go | 21 +- 12 files changed, 2006 insertions(+), 1 deletion(-) create mode 100644 cmd/notifications.go create mode 100644 cmd/notifications_test.go create mode 100644 cmd/testdata/notifications/add_https.golden create mode 100644 cmd/testdata/notifications/add_slack.golden create mode 100644 cmd/testdata/notifications/deliveries.golden create mode 100644 cmd/testdata/notifications/ls.golden create mode 100644 cmd/testdata/notifications/rotate.golden create mode 100644 cmd/testdata/notifications/show_failing.golden create mode 100644 cmd/testdata/notifications/show_slack.golden create mode 100644 cmd/testdata/notifications/subscriptions.golden diff --git a/cmd/json_output_test.go b/cmd/json_output_test.go index 395e987..720da25 100644 --- a/cmd/json_output_test.go +++ b/cmd/json_output_test.go @@ -51,6 +51,12 @@ var hintFragments = []string{ "already exists", "showing the", "the next runs", + notifSecretNote, + "Store it now", + "previous secret keeps working", + "send a test", + "nan notifications", + "turned off", } // jsonFixtures writes the files the write commands read. @@ -74,6 +80,10 @@ func jsonFixtures(t *testing.T) (automation, trigger, value string) { func jsonCases(t *testing.T) map[string][]jsonCase { automation, trigger, value := jsonFixtures(t) + hook := filepath.Join(t.TempDir(), "hook-url") + if err := os.WriteFile(hook, []byte(testHookURL+"\n"), 0o600); err != nil { + t.Fatal(err) + } return map[string][]jsonCase{ "nan approvals ls": {{args: []string{"approvals", "ls", "--json"}}}, "nan approvals show": {{args: []string{"approvals", "show", testRunID, "--json"}}}, @@ -102,6 +112,25 @@ func jsonCases(t *testing.T) map[string][]jsonCase { {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--json"}}, {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--url", "--secret", "--no-grace", "--json"}}, }, + "nan notifications add": { + {args: []string{"notifications", "add", "deploys", "--kind", "slack", "--url-file", hook, "--json"}}, + // The one-time signing secret is in the JSON; the note about it + // goes to stderr only. + {args: []string{"notifications", "add", "ci", "--kind", "https", "--url-stdin", "--event", "run.failed", "--json"}, stdin: testHookHTTPS}, + }, + "nan notifications deliveries": {{args: []string{"notifications", "deliveries", "ci receiver", "--limit", "3", "--json"}}}, + "nan notifications edit": { + {args: []string{"notifications", "edit", "team alerts", "--disable", "--json"}}, + {args: []string{"notifications", "edit", "ci receiver", "--url-file", hook, "--json"}}, + }, + "nan notifications ls": {{args: []string{"notifications", "ls", "--json"}}}, + "nan notifications rotate": {{args: []string{"notifications", "rotate", "ci receiver", "--json"}}}, + "nan notifications show": {{args: []string{"notifications", "show", "ci receiver", "--json"}}}, + "nan notifications subscriptions": { + {args: []string{"notifications", "subscriptions", "ci receiver", "--json"}}, + {args: []string{"notifications", "subscriptions", "team alerts", "--add", "run.finished", "--json"}}, + }, + "nan notifications test": {{args: []string{"notifications", "test", "team alerts", "--json"}}}, "nan run": { {args: []string{"run", "review", "--json"}, lines: true}, {args: []string{"run", "review", "--detach", "--json"}, code: exitDetached}, @@ -182,6 +211,9 @@ func TestJSONStdoutIsOnlyJSON(t *testing.T) { t.Errorf("stdout carries the hint %q:\n%s", h, stdout) } } + if strings.Contains(stdout+stderr+msg, "HOOKSECRET") { + t.Error("the webhook URL was printed") + } if strings.Contains(stdout+stderr+msg, testValue) { t.Error("the secret value was printed") } @@ -259,6 +291,7 @@ func runJSONCase(t *testing.T, c jsonCase) (code int, msg, stdout, stderr string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path toWrite := p == "/api/auth/me" || p == "/api/account/secrets" || strings.HasPrefix(p, "/v1/secrets") || + strings.HasPrefix(p, "/v1/notification-channels") || strings.HasSuffix(p, "/triggers") || strings.Contains(p, "/triggers/") || (strings.HasPrefix(p, "/v1/automations") && r.Method != http.MethodGet && !strings.HasSuffix(p, "/runs")) if toWrite { diff --git a/cmd/notifications.go b/cmd/notifications.go new file mode 100644 index 0000000..0775c26 --- /dev/null +++ b/cmd/notifications.go @@ -0,0 +1,1212 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "text/tabwriter" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/spf13/cobra" +) + +// Notification channels (helmcode/nan-devops +// AGENT-AUTOMATIONS-PHASE3-NOTIFICATIONS.md): Slack, Microsoft Teams, +// Discord or any HTTPS endpoint, told when an automation run needs approval, +// failed or finished, when an automation is disabled and when a webhook +// trigger keeps failing. Everything goes through /v1/notification-channels: +// a CLI session, or a platform token with notifications:read (reads) or +// notifications:write (changes). +// +// A channel's URL is a credential (whoever has it can post to the channel): +// it is read from a file or a pipe, sent in the body of a create or a +// replace, and never printed; the platform only ever shows url_hint. An +// https channel's signing secret is printed once. + +const ( + notificationsDocs = "https://nan.builders/docs/notifications" + notificationsURL = runs.PortalURL + "/settings" + + scopeNotificationsRead = "notifications:read" + scopeNotificationsWrite = "notifications:write" + + // maxDeliveriesLimit is the platform's cap on ?limit=. + maxDeliveriesLimit = 100 +) + +// notifSecretNote goes to stderr after a signing secret was printed. +const notifSecretNote = "Store it now: it is never shown again. Lost it? Issue a new one with: nan notifications rotate" + +var ( + notifJSON bool + + notifAddKind string + notifAddEvents []string + notifAddNoEvents bool + notifAddAuto string + + notifURLFile string + notifURLStdin bool + notifURLArg string + + notifEditName string + notifEditEnable bool + notifEditDisable bool + + notifRmYes bool + + notifDeliveriesLimit int + + notifSubsSet []string + notifSubsAdd []string + notifSubsRemove []string + notifSubsClear bool +) + +var notifVerbHints = map[string]string{ + "create": "add", "new": "add", "list": "ls", "get": "show", "update": "edit", "enable": "edit NAME --enable", + "disable": "edit NAME --disable", "rotate-secret": "rotate", "events": "subscriptions", "subs": "subscriptions", + "logs": "deliveries", "history": "deliveries", "ping": "test", "send": "test", +} + +const notifCredentialHelp = `Authentication, first match wins: --token-file, your session from +nan auth login, NAN_TOKEN, a platform token saved with nan auth login +--api-token. A platform token needs notifications:read to list and show +channels, their deliveries and subscriptions, and notifications:write to +change them (finding a channel by NAME needs notifications:read too; +naming an automation needs automations:read). Those scopes are added to a +token in the portal only, Settings > Tokens, signed in through the +browser. API keys (sk-) and workspace keys never manage notifications.` + +var notificationsCmd = &cobra.Command{ + Use: "notifications", + Short: "Send automation events to Slack, Teams, Discord or your own HTTPS endpoint", + Long: `Manage where NaN tells you about your automations: a Slack, Microsoft +Teams or Discord channel (through its incoming webhook URL), or your own +HTTPS endpoint (signed, like NaN's inbound webhooks). + +Events: approval.pending (a run waits for your approval), run.failed +(failed or timed out), automation.disabled, trigger.failing (a webhook +trigger keeps getting bad signatures) and run.finished. A new channel gets +all of them except run.finished, which is opt-in. Only automation runs +notify. Many notifications at once arrive as one digest. + +A webhook URL is a credential: whoever has it can post to your channel. +It is read from a file or a pipe, never from the command line, where it +would stay in your shell history: + nan notifications add team-alerts --kind slack --url-file ./slack-url.txt + pbpaste | nan notifications add ci --kind https --url-stdin +NaN never shows it again: you see the host and its last characters. + +After 20 failed deliveries in a row a channel is disabled; fix the +receiver (or replace the URL) and turn it back on with: + nan notifications edit NAME --enable + +` + notifCredentialHelp + ` + +The portal does all of this too, Settings > Notifications at ` + notificationsURL + `. + +Docs: ` + notificationsDocs, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + if looksLikeURL(args[0]) { + return urlOnCommandLine() + } + shown := runs.Truncate(runs.SanitizeLine(args[0]), 64) + if hint, ok := notifVerbHints[args[0]]; ok { + return usageErrorf("there is no nan notifications %s; try: nan notifications %s", shown, hint) + } + return usageErrorf("unknown command %q for \"nan notifications\"; try: nan notifications --help", shown) + } + return cmd.Help() + }, +} + +var notifLsCmd = &cobra.Command{ + Use: "ls", + Short: "List your notification channels", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doNotifLs(cmd.Context(), env) }) + }, +} + +var notifShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show one channel: destination, status, subscriptions", + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doNotifShow(cmd.Context(), env, args[0]) }) + }, +} + +var notifAddCmd = &cobra.Command{ + Use: "add NAME --kind slack|teams|discord|https (--url-file PATH | --url-stdin)", + Short: "Add a channel; an https channel's signing secret is shown once", + Long: `Add a notification channel. + +--kind is slack, teams, discord or https, fixed once the channel exists. +The URL comes from --url-file PATH or a pipe with --url-stdin, never from +the command line. Where to get it: + slack an incoming webhook (api.slack.com/apps > your app > Incoming + Webhooks): https://hooks.slack.com/services/... + teams a Workflows "When a Teams webhook request is received" flow, or + an incoming webhook connector: https://...webhook.office.com/..., + ...logic.azure.com/... or ...powerplatform.com/... + discord Server Settings > Integrations > Webhooks > Copy Webhook URL: + https://discord.com/api/webhooks/... + https your own endpoint on port 443; NaN signs every request + (X-Nan-Signature, the same scheme as NaN's inbound webhooks) + +Events: --event, repeated or comma-separated, out of approval.pending, +run.failed, run.finished, automation.disabled and trigger.failing. Without +it the channel gets every event but run.finished; --no-events adds it with +none (set them later with nan notifications subscriptions). --automation +limits the events to one automation (a name or an id). + +An https channel's signing secret is printed once, to stdout (with --json, +as signing_secret). Store it then: it is never shown again. + +Up to 10 channels per account. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Example: ` nan notifications add team-alerts --kind slack --url-file ./slack-url.txt + pbpaste | nan notifications add deploys --kind discord --url-stdin --event run.failed,run.finished + nan notifications add ci --kind https --url-file url.txt --automation "PR review" --json`, + Args: oneChannelName, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + in, err := buildNotifAdd(cmd, args[0]) + if err != nil { + return err + } + return withWriteEnv(func(env *runsEnv) error { return doNotifAdd(cmd.Context(), env, in) }) + }, +} + +var notifEditCmd = &cobra.Command{ + Use: "edit [--name NEW] [--enable | --disable] [--url-file PATH | --url-stdin]", + Short: "Rename a channel, turn it on or off, or replace its URL", + Long: `Rename a channel, turn it on or off, or replace its URL (from --url-file +PATH or --url-stdin, never the command line). The kind cannot change. + +--enable turns on a channel you disabled or the platform disabled after +repeated failures, and resets its failure count. Replacing the URL resets +the count too and turns back on a channel the platform disabled; one you +disabled yourself stays off until --enable. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Example: ` nan notifications edit team-alerts --enable + nan notifications edit team-alerts --url-file ./new-url.txt + nan notifications edit team-alerts --name "team alerts" --disable`, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if notifURLArg != "" || cmd.Flags().Changed("url") { + return urlOnCommandLine() + } + var p api.NotificationChannelPatch + if cmd.Flags().Changed("name") { + if err := checkChannelName(notifEditName); err != nil { + return err + } + name := notifEditName + p.Name = &name + } + switch { + case notifEditEnable && notifEditDisable: + return usageErrorf("give --enable or --disable, not both") + case notifEditEnable, notifEditDisable: + on := notifEditEnable + p.Enabled = &on + } + if notifURLFile != "" || notifURLStdin { + u, err := readHookURL() + if err != nil { + return err + } + p.URL = &u + } + if p.Name == nil && p.Enabled == nil && p.URL == nil { + return usageErrorf("nothing to change: give --name, --enable, --disable, --url-file or --url-stdin") + } + return withWriteEnv(func(env *runsEnv) error { return doNotifEdit(cmd.Context(), env, args[0], p) }) + }, +} + +var notifRmCmd = &cobra.Command{ + Use: "rm [--yes]", + Aliases: []string{"delete", "remove"}, + Short: "Delete a channel", + Long: `Delete a channel: it stops receiving notifications at once, and its +delivery log goes with it. Asks before deleting on a terminal; elsewhere, +confirm with --yes. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doNotifRm(cmd.Context(), env, args[0]) }) + }, +} + +var notifTestCmd = &cobra.Command{ + Use: "test ", + Short: "Send a test notification now (exit 1 when it is not delivered)", + Long: `Send a test notification to a channel now and say how it went: exit 0 +when it was delivered, 1 when it was not (with the reason). A disabled +channel is not tested: turn it on first. Up to 5 tests in a row, then one a +minute. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doNotifTest(cmd.Context(), env, args[0]) }) + }, +} + +var notifRotateCmd = &cobra.Command{ + Use: "rotate ", + Short: "Issue a new signing secret for an https channel, shown once", + Long: `Issue a new signing secret for an https channel. It is printed once, to +stdout (with --json, as signing_secret). For 24 hours every request carries +both signatures (the new one first), so you can update your receiver; then +only the new one. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doNotifRotate(cmd.Context(), env, args[0]) }) + }, +} + +var notifDeliveriesCmd = &cobra.Command{ + Use: "deliveries [--limit N]", + Short: "The channel's recent delivery attempts, newest first", + Long: `The channel's recent delivery attempts, newest first (kept 30 days): the +event, how it went, the HTTP status and the error class. Never a payload, +a URL or a response body. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if notifDeliveriesLimit < 1 || notifDeliveriesLimit > maxDeliveriesLimit { + return usageErrorf("--limit is 1 to %d", maxDeliveriesLimit) + } + return withWriteEnv(func(env *runsEnv) error { return doNotifDeliveries(cmd.Context(), env, args[0]) }) + }, +} + +var notifSubsCmd = &cobra.Command{ + Use: "subscriptions [--set|--add|--remove EVENT[=AUTOMATION]]... [--clear]", + Short: "List or change which events a channel receives", + Long: `Without flags, list the events a channel receives. With them, change the +list: + --add EVENT[=AUTOMATION] receive EVENT (repeat for more) + --remove EVENT[=AUTOMATION] stop receiving it + --set EVENT[=AUTOMATION] replace the whole list with these + --clear receive nothing +EVENT alone is for every automation; EVENT=AUTOMATION (a name or an id) for +that one only. Events: approval.pending, run.failed, run.finished, +automation.disabled, trigger.failing. Up to 50 per channel. + +` + notifCredentialHelp + ` + +Docs: ` + notificationsDocs, + Example: ` nan notifications subscriptions team-alerts + nan notifications subscriptions team-alerts --add run.finished="PR review" + nan notifications subscriptions team-alerts --set run.failed --set approval.pending`, + Args: oneChannel, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + changes := 0 + for _, n := range []int{len(notifSubsSet), len(notifSubsAdd) + len(notifSubsRemove)} { + if n > 0 { + changes++ + } + } + if notifSubsClear { + changes++ + } + if changes > 1 { + return usageErrorf("give --set, --add/--remove or --clear: --set and --clear replace the whole list, --add and --remove change it") + } + for _, list := range [][]string{notifSubsSet, notifSubsAdd, notifSubsRemove} { + for _, s := range list { + if _, _, err := parseSubscriptionArg(s); err != nil { + return err + } + } + } + return withWriteEnv(func(env *runsEnv) error { return doNotifSubs(cmd.Context(), env, args[0], changes > 0) }) + }, +} + +func init() { + rootCmd.AddCommand(notificationsCmd) + notificationsCmd.AddCommand(notifLsCmd, notifShowCmd, notifAddCmd, notifEditCmd, notifRmCmd, notifTestCmd, + notifRotateCmd, notifDeliveriesCmd, notifSubsCmd) + notificationsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) + + for _, c := range []*cobra.Command{notifLsCmd, notifShowCmd, notifAddCmd, notifEditCmd, notifTestCmd, notifRotateCmd, + notifDeliveriesCmd, notifSubsCmd} { + c.Flags().BoolVar(¬ifJSON, "json", false, "Print the answer as JSON") + } + for _, c := range []*cobra.Command{notifAddCmd, notifEditCmd} { + c.Flags().StringVar(¬ifURLFile, "url-file", "", "Read the webhook URL from `PATH`") + c.Flags().BoolVar(¬ifURLStdin, "url-stdin", false, "Read the webhook URL from stdin (a pipe, never a terminal)") + // --url exists only to refuse it with a reason: a URL typed on the + // command line stays in the shell history. + c.Flags().StringVar(¬ifURLArg, "url", "", "Not accepted: use --url-file or --url-stdin") + _ = c.Flags().MarkHidden("url") + } + f := notifAddCmd.Flags() + f.StringVar(¬ifAddKind, "kind", "", "slack, teams, discord or https") + f.StringSliceVar(¬ifAddEvents, "event", nil, "An event to receive (repeat, or comma-separate); default: all but run.finished") + f.BoolVar(¬ifAddNoEvents, "no-events", false, "Add the channel with no events") + f.StringVar(¬ifAddAuto, "automation", "", "Only this automation's events (a name or an id)") + + notifEditCmd.Flags().StringVar(¬ifEditName, "name", "", "The new name") + notifEditCmd.Flags().BoolVar(¬ifEditEnable, "enable", false, "Turn the channel on (resets its failure count)") + notifEditCmd.Flags().BoolVar(¬ifEditDisable, "disable", false, "Turn the channel off") + + notifRmCmd.Flags().BoolVar(¬ifRmYes, "yes", false, "Delete without asking") + + notifDeliveriesCmd.Flags().IntVar(¬ifDeliveriesLimit, "limit", 20, "How many attempts to show (1 to 100)") + + s := notifSubsCmd.Flags() + s.StringArrayVar(¬ifSubsSet, "set", nil, "Replace the list with EVENT[=AUTOMATION] (repeat)") + s.StringArrayVar(¬ifSubsAdd, "add", nil, "Add EVENT[=AUTOMATION] (repeat)") + s.StringArrayVar(¬ifSubsRemove, "remove", nil, "Remove EVENT[=AUTOMATION] (repeat)") + s.BoolVar(¬ifSubsClear, "clear", false, "Remove every subscription") +} + +// ─── arguments ───────────────────────────────────────────────────────────── + +// looksLikeURL is an argument that is (or starts like) a webhook URL. +func looksLikeURL(s string) bool { + l := strings.ToLower(strings.TrimSpace(s)) + return strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.Contains(l, "hooks.slack.com") || + strings.Contains(l, "/api/webhooks/") || strings.Contains(l, "webhook.office.com") +} + +// urlOnCommandLine is the refusal of a URL given as an argument. It never +// repeats the URL. +func urlOnCommandLine() *ExitError { + return usageErrorf("a webhook URL is a credential: give it with --url-file PATH or --url-stdin (a pipe), never on the command line. " + + "If you typed it, it is now in your shell history: delete that webhook at the provider, create a new one and add it from a file") +} + +func oneChannel(cmd *cobra.Command, args []string) error { + switch { + case len(args) == 0: + return usageErrorf("missing the channel's NAME or id; see: nan notifications ls") + case looksLikeURL(args[0]) || (len(args) > 1 && looksLikeURL(args[1])): + return urlOnCommandLine() + case len(args) > 1: + return usageErrorf("one channel at a time (quote a NAME with spaces: \"team alerts\")") + } + return nil +} + +func oneChannelName(cmd *cobra.Command, args []string) error { + switch { + case len(args) == 0: + return usageErrorf("missing the channel's name, like team-alerts") + case looksLikeURL(args[0]) || (len(args) > 1 && looksLikeURL(args[len(args)-1])): + return urlOnCommandLine() + case len(args) > 1: + return usageErrorf("nan notifications add takes one NAME (quote a name with spaces); the URL goes in --url-file or --url-stdin") + } + return checkChannelName(args[0]) +} + +// checkChannelName is the platform's rule: 1 to 64 characters, no control +// characters. +func checkChannelName(name string) error { + n := len([]rune(name)) + if n == 0 || n > 64 || strings.ContainsFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) }) { + return usageErrorf("a channel name is 1 to 64 characters, without control characters") + } + if looksLikeURL(name) { + return urlOnCommandLine() + } + return nil +} + +var urlInputFlags = secretInputFlags{"--url-file", "--url-stdin", "pbpaste | nan notifications add NAME --kind slack --url-stdin"} + +// readHookURL reads the URL from --url-file or a piped --url-stdin. The +// platform validates it; errors never include it. +func readHookURL() (string, error) { + switch { + case notifURLFile != "" && notifURLStdin: + return "", usageErrorf("give --url-file or --url-stdin, not both") + case notifURLFile == "" && !notifURLStdin: + return "", usageErrorf("the webhook URL comes from --url-file PATH or --url-stdin (a pipe), never from the command line") + } + u, err := readSecretInput(urlInputFlags, notifURLFile, notifURLStdin) + if err != nil { + var exit *ExitError + if errors.As(err, &exit) { + exit.Err = errors.New(strings.Replace(exit.Err.Error(), "the value", "the URL", 1)) + } + return "", err + } + u = strings.TrimSpace(u) + if u == "" { + return "", usageErrorf("the URL is empty") + } + return u, nil +} + +type notifAdd struct { + req api.CreateNotificationChannelRequest + automation string +} + +func buildNotifAdd(cmd *cobra.Command, name string) (*notifAdd, error) { + if notifURLArg != "" || cmd.Flags().Changed("url") { + return nil, urlOnCommandLine() + } + switch notifAddKind { + case api.NotificationKindSlack, api.NotificationKindTeams, api.NotificationKindDiscord, api.NotificationKindHTTPS: + case "": + return nil, usageErrorf("--kind is required: slack, teams, discord or https") + default: + return nil, usageErrorf("--kind is slack, teams, discord or https") + } + in := ¬ifAdd{req: api.CreateNotificationChannelRequest{Name: name, Kind: notifAddKind}, automation: notifAddAuto} + switch { + case notifAddNoEvents && len(notifAddEvents) > 0: + return nil, usageErrorf("give --event or --no-events, not both") + case notifAddNoEvents: + none := []string{} + in.req.Events = &none + case len(notifAddEvents) > 0: + events := []string{} + for _, e := range notifAddEvents { + e = strings.TrimSpace(e) + if err := checkEvent(e); err != nil { + return nil, err + } + if !slices.Contains(events, e) { + events = append(events, e) + } + } + in.req.Events = &events + } + u, err := readHookURL() + if err != nil { + return nil, err + } + in.req.URL = u + return in, nil +} + +func checkEvent(e string) error { + if slices.Contains(api.NotificationEvents, e) { + return nil + } + return usageErrorf("%q is not an event: use %s", runs.Truncate(runs.SanitizeLine(e), 40), strings.Join(api.NotificationEvents, ", ")) +} + +// parseSubscriptionArg splits EVENT[=AUTOMATION] (an event never has '='). +func parseSubscriptionArg(s string) (event, automation string, err error) { + event, automation, _ = strings.Cut(s, "=") + event = strings.TrimSpace(event) + if event == "" { + return "", "", usageErrorf("a subscription is EVENT or EVENT=AUTOMATION, like run.failed or run.failed=\"PR review\"") + } + if err := checkEvent(event); err != nil { + return "", "", err + } + return event, strings.TrimSpace(automation), nil +} + +// ─── platform plumbing ───────────────────────────────────────────────────── + +// notifOff is the answer when the platform has no notification channels for +// the member. +func notifOff() *ExitError { + return usageErrorf("notifications are not available for your account yet. When they are, manage them here or in the portal, "+ + "Settings > Notifications at %s. See %s", notificationsURL, notificationsDocs) +} + +// requireNotifications asks the platform, for a session, whether the +// member has notifications (features.notifications) and takes them from the +// CLI (features.write_api). A token has no /api/auth/me: the platform's +// 404 answers for it. When the question itself fails for another reason +// than an expired session, the request goes ahead and the platform decides. +func requireNotifications(ctx context.Context, env *runsEnv) error { + if env.cred.kind != credSession { + return nil + } + f, err := env.client.Features(ctx) + if errors.Is(err, api.ErrSessionExpired) { + return authError(env.cred) + } + if err != nil { + return nil + } + if !f.Notifications { + return notifOff() + } + if !f.WriteAPI { + return usageErrorf("managing notification channels from the CLI is not available for your account yet: do it in the portal, "+ + "Settings > Notifications at %s, signed in through the browser. See %s", notificationsURL, notificationsDocs) + } + return nil +} + +// notifOp is what a request was doing, for the messages of notifExit. +type notifOp struct { + scope string // the platform-token scope of the request + channel string // the channel's name or id, when there is one + kind string // the channel's kind, when known + test bool +} + +// notifExit maps a refused notification request onto an exit code and a +// message that says what to do. The platform's messages never carry a URL +// or a secret; they are sanitised like any other text from the network. +func notifExit(err error, cred credential, op notifOp) *ExitError { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return apiExit(err, cred) + } + msg := runs.SanitizeLine(apiErr.Message) + name := shellArg(op.channel) + switch apiErr.Code { + case "invalid_url": + return usageErrorf("the URL was refused: %s. %s", msg, kindURLHint(op.kind)) + case "name_taken": + return usageErrorf("that name is already taken by another of your notification channels") + case "too_many_channels": + return usageErrorf("you have 10 notification channels, the most an account can keep: delete one first (nan notifications rm NAME)") + case "automation_not_found": + return usageErrorf("that automation was not found among yours; see: nan automations ls") + case "channel_disabled": + return usageErrorf("%s is disabled: turn it on first with: nan notifications edit %s --enable", runs.SanitizeLine(op.channel), name) + case "signing_not_supported": + return usageErrorf("only https channels have a signing secret") + case "rate_limited": + if op.test { + return exitf(exitUnavailable, "too many test notifications recently: up to 5 in a row, then one a minute; try again shortly") + } + return exitf(exitUnavailable, "too many changes to your notification channels recently: try again later") + case "notifications_unavailable": + return exitf(exitUnavailable, "notifications are temporarily unavailable; try again in a few minutes") + case "token_scope_denied": + return exitf(exitAuth, "%s lacks a scope: %s. Create a token with the %s scope in the portal, Settings > Tokens at %s, "+ + "signed in through the browser (the notifications scopes can only be added there)", cred.describe(), msg, op.scope, runs.PortalURL) + case "credential_not_allowed": + if cred.kind == credSession { + return exitf(exitAuth, "the platform does not take this session for notification channels: it is from an older sign-in. "+ + "Sign in again with: nan auth login") + } + return exitf(exitAuth, "%s cannot manage notification channels: use a CLI session (nan auth login) or a platform token with the %s scope", + cred.describe(), op.scope) + case "workspace_key_not_allowed": + return exitf(exitAuth, "a workspace key never manages notification channels: use your own session (nan auth login) "+ + "or a platform token with the %s scope", op.scope) + case "browser_session_required": + return writeAPIOff("notification channels") + case "not_found", "": + if apiErr.Status != 404 { + break + } + if op.channel == "" || apiErr.Message == "Not found." { + // The mount itself answered: no notifications for the member. + return notifOff() + } + return usageErrorf("no notification channel %s; see: nan notifications ls", runs.Truncate(runs.SanitizeLine(op.channel), 64)) + } + return apiExit(err, cred) +} + +// kindURLHint is what a URL of each kind looks like. +func kindURLHint(kind string) string { + switch kind { + case api.NotificationKindSlack: + return "A Slack URL is an incoming webhook: https://hooks.slack.com/services/..." + case api.NotificationKindTeams: + return "A Teams URL is a Workflows or incoming webhook URL on webhook.office.com, logic.azure.com or powerplatform.com" + case api.NotificationKindDiscord: + return "A Discord URL is a channel webhook: https://discord.com/api/webhooks/..." + case api.NotificationKindHTTPS: + return "An https URL is https on port 443 with a public DNS name (no IP address, no nan.builders)" + } + return "See " + notificationsDocs +} + +var plainShellArg = regexp.MustCompile(`^[A-Za-z0-9._@%+=:,/-]+$`) + +// shellArg quotes a (sanitised) channel name for a command to copy. +func shellArg(s string) string { + s = runs.SanitizeLine(s) + if plainShellArg.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func listChannels(ctx context.Context, env *runsEnv) (*api.NotificationChannelList, error) { + list, err := env.client.ListNotificationChannels(ctx) + if err != nil { + return nil, notifExit(err, env.cred, notifOp{scope: scopeNotificationsRead}) + } + return list, nil +} + +func findChannel(list *api.NotificationChannelList, ref string) *api.NotificationChannel { + for i := range list.Data { + if list.Data[i].ID == ref { + return &list.Data[i] + } + } + for i := range list.Data { + if strings.EqualFold(list.Data[i].Name, ref) { + return &list.Data[i] + } + } + return nil +} + +// resolveChannel finds a channel by NAME or id. forWrite lets a token +// without notifications:read go ahead with an id it cannot look up. +func resolveChannel(ctx context.Context, env *runsEnv, ref string, forWrite bool) (*api.NotificationChannel, error) { + if err := requireNotifications(ctx, env); err != nil { + return nil, err + } + list, err := env.client.ListNotificationChannels(ctx) + if err != nil { + var apiErr *api.APIError + if forWrite && errors.As(err, &apiErr) && apiErr.Code == "token_scope_denied" { + if runIDPattern.MatchString(ref) { + return &api.NotificationChannel{ID: ref, Name: ref}, nil + } + return nil, exitf(exitAuth, "%s cannot find a channel by name without the %s scope: give the channel id instead, "+ + "or use a token with %s too", env.cred.describe(), scopeNotificationsRead, scopeNotificationsRead) + } + return nil, notifExit(err, env.cred, notifOp{scope: scopeNotificationsRead}) + } + c := findChannel(list, ref) + if c == nil { + return nil, usageErrorf("no notification channel named %q; see: nan notifications ls", runs.Truncate(runs.SanitizeLine(ref), 64)) + } + return c, nil +} + +// automationNames maps automation ids to names, best effort: a token +// without automations:read still gets the ids. +func automationNames(ctx context.Context, env *runsEnv, subs []api.NotificationSubscription) map[string]string { + need := false + for _, s := range subs { + need = need || s.AutomationID != nil + } + names := map[string]string{} + if !need { + return names + } + if list, err := env.client.ListAutomations(ctx); err == nil { + for _, a := range list.Data { + names[a.ID] = a.Name + } + } + return names +} + +// ─── output ──────────────────────────────────────────────────────────────── + +func channelStatus(c *api.NotificationChannel) string { + if c.Enabled { + if c.ConsecutiveFailures > 0 { + return fmt.Sprintf("on, %d failed in a row", c.ConsecutiveFailures) + } + return "on" + } + if c.DisabledReason == nil { + return "off" + } + switch *c.DisabledReason { + case "member": + return "off" + case "failing": + return "off: failing" + case "invalid_url": + return "off: URL refused" + } + return "off: " + runs.SanitizeLine(*c.DisabledReason) +} + +// disabledHelp says why a channel is off and how to turn it back on. +func disabledHelp(c *api.NotificationChannel) string { + if c.Enabled || c.DisabledReason == nil { + return "" + } + name := shellArg(c.Name) + switch *c.DisabledReason { + case "member": + return "turned off by you; turn it on with: nan notifications edit " + name + " --enable" + case "failing": + return fmt.Sprintf("turned off after %d failed deliveries in a row; fix the receiver (or replace the URL with --url-file) "+ + "and turn it on with: nan notifications edit %s --enable", c.ConsecutiveFailures, name) + case "invalid_url": + return "turned off: its host resolved to an address NaN does not send to; replace the URL with: nan notifications edit " + + name + " --url-file PATH" + } + return "" +} + +func lastDelivery(c *api.NotificationChannel) string { + if c.LastDeliveryAt == nil { + return "never" + } + s := localTime(*c.LastDeliveryAt) + if c.LastDeliveryStatus != nil { + s += " " + runs.SanitizeLine(*c.LastDeliveryStatus) + } + return s +} + +// eventsSummary is the EVENTS column: how many events, for which +// automations. +func eventsSummary(subs []api.NotificationSubscription) string { + if len(subs) == 0 { + return "none" + } + events := map[string]bool{} + autos := map[string]bool{} + all := false + for _, s := range subs { + events[s.Event] = true + if s.AutomationID == nil { + all = true + } else { + autos[*s.AutomationID] = true + } + } + scope := "" + switch { + case all && len(autos) > 0: + scope = fmt.Sprintf("all automations + %d", len(autos)) + case all: + scope = "all automations" + default: + scope = plural(len(autos), "automation") + } + return plural(len(events), "event") + ", " + scope +} + +func plural(n int, what string) string { + if n == 1 { + return "1 " + what + } + return fmt.Sprintf("%d %ss", n, what) +} + +func doNotifLs(ctx context.Context, env *runsEnv) error { + if err := requireNotifications(ctx, env); err != nil { + return err + } + list, err := listChannels(ctx, env) + if err != nil { + return err + } + if notifJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintln(env.stderr, "No notification channels yet. Add one with: nan notifications add NAME --kind slack --url-file PATH") + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tKIND\tDESTINATION\tSTATUS\tEVENTS\tLAST DELIVERY\tCHANGED FROM") + var off []string + for i := range list.Data { + c := &list.Data[i] + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", runs.Truncate(runs.SanitizeLine(c.Name), 64), runs.SanitizeLine(c.Kind), + runs.Truncate(runs.SanitizeLine(c.URLHint), 60), channelStatus(c), eventsSummary(c.Subscriptions), lastDelivery(c), + dashIfEmpty(updatedByLabel(c.UpdatedBy))) + if h := disabledHelp(c); h != "" && *c.DisabledReason != "member" { + off = append(off, runs.SanitizeLine(c.Name)+": "+h) + } + } + if err := tw.Flush(); err != nil { + return err + } + for _, o := range off { + fmt.Fprintln(env.stderr, o) + } + return nil +} + +func doNotifShow(ctx context.Context, env *runsEnv, ref string) error { + c, err := resolveChannel(ctx, env, ref, false) + if err != nil { + return err + } + if notifJSON { + return printJSON(env.stdout, c, true) + } + printChannel(ctx, env, c) + return nil +} + +func printChannel(ctx context.Context, env *runsEnv, c *api.NotificationChannel) { + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + row := func(k, v string) { + if v != "" { + fmt.Fprintf(tw, "%s\t%s\n", k, v) + } + } + row("name", runs.SanitizeLine(c.Name)) + row("id", runs.SanitizeLine(c.ID)) + row("kind", runs.SanitizeLine(c.Kind)) + row("destination", runs.SanitizeLine(c.URLHint)) + row("status", channelStatus(c)) + row("why", disabledHelp(c)) + row("last delivery", lastDelivery(c)) + if c.Signing != nil { + signing := "one secret (rotate with: nan notifications rotate " + shellArg(c.Name) + ")" + if c.Signing.PreviousExpiresAt != nil { + signing = "two secrets: the previous one works until " + localTime(*c.Signing.PreviousExpiresAt) + } + row("signing", signing) + } + row("last changed from", updatedByLabel(c.UpdatedBy)) + row("created", localTime(c.CreatedAt)) + row("updated", localTime(c.UpdatedAt)) + _ = tw.Flush() + fmt.Fprintln(env.stdout) + printSubscriptions(env, c.Subscriptions, automationNames(ctx, env, c.Subscriptions)) +} + +func printSubscriptions(env *runsEnv, subs []api.NotificationSubscription, names map[string]string) { + if len(subs) == 0 { + fmt.Fprintln(env.stdout, "No subscriptions: this channel receives nothing.") + return + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "EVENT\tAUTOMATION") + for _, s := range subs { + auto := "all automations" + if s.AutomationID != nil { + auto = runs.SanitizeLine(*s.AutomationID) + if n, ok := names[*s.AutomationID]; ok { + auto = runs.Truncate(runs.SanitizeLine(n), 64) + " (" + auto + ")" + } + } + fmt.Fprintf(tw, "%s\t%s\n", runs.SanitizeLine(s.Event), auto) + } + _ = tw.Flush() +} + +func doNotifAdd(ctx context.Context, env *runsEnv, in *notifAdd) error { + if err := requireNotifications(ctx, env); err != nil { + return err + } + if in.automation != "" { + id, err := resolveAutomation(ctx, env, in.automation) + if err != nil { + return err + } + in.req.AutomationID = &id + } + c, err := env.client.CreateNotificationChannel(ctx, in.req) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, kind: in.req.Kind}) + } + if notifJSON { + if err := printJSON(env.stdout, c, true); err != nil { + return err + } + if c.SigningSecret != nil { + fmt.Fprintln(env.stderr, notifSecretNote) + } + return nil + } + fmt.Fprintf(env.stdout, "notification channel %s added: %s, %s, %s\n", runs.SanitizeLine(c.Name), runs.SanitizeLine(c.Kind), + runs.SanitizeLine(c.URLHint), eventsSummary(c.Subscriptions)) + if c.SigningSecret != nil { + fmt.Fprintf(env.stdout, "signing secret: %s\n", runs.SanitizeLine(*c.SigningSecret)) + fmt.Fprintln(env.stderr, notifSecretNote) + } + fmt.Fprintf(env.stderr, "send a test: nan notifications test %s\n", shellArg(c.Name)) + return nil +} + +func doNotifEdit(ctx context.Context, env *runsEnv, ref string, p api.NotificationChannelPatch) error { + existing, err := resolveChannel(ctx, env, ref, true) + if err != nil { + return err + } + c, err := env.client.UpdateNotificationChannel(ctx, existing.ID, p) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, channel: existing.Name, kind: existing.Kind}) + } + if notifJSON { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "notification channel %s updated: %s\n", runs.SanitizeLine(c.Name), channelStatus(c)) + if h := disabledHelp(c); h != "" { + fmt.Fprintln(env.stderr, h) + } + return nil +} + +func doNotifRm(ctx context.Context, env *runsEnv, ref string) error { + existing, err := resolveChannel(ctx, env, ref, true) + if err != nil { + return err + } + name := runs.SanitizeLine(existing.Name) + if err := confirm(env, notifRmYes, "delete notification channel "+name+"? It stops receiving notifications at once"); err != nil { + return err + } + if err := env.client.DeleteNotificationChannel(ctx, existing.ID); err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, channel: existing.Name}) + } + fmt.Fprintf(env.stdout, "notification channel %s deleted\n", name) + return nil +} + +// testFailureHint is what to do about a test that was not delivered, by +// the platform's error class. +func testFailureHint(code, name string) string { + switch code { + case "timeout", "connect": + return "the receiver did not answer in time; check it is up and try again" + case "tls": + return "the TLS handshake failed: the receiver needs a valid certificate for its name" + case "dns": + return "the URL's host does not resolve" + case "blocked_address", "invalid_url": + return "the URL's host resolves to an address NaN does not send to (private or NaN's own); use a public endpoint" + case "redirect": + return "the URL answered with a redirect, which NaN does not follow: use the final URL" + case "http_4xx": + return "the provider refused it: the webhook may have been deleted or revoked; replace the URL with: nan notifications edit " + + shellArg(name) + " --url-file PATH" + case "http_5xx": + return "the receiver answered with a server error; try again later" + case "rate_limited": + return "this channel's delivery budget is spent for now (30 a minute, 1000 a day); try again later" + case "channel_disabled": + return "the channel is disabled: turn it on with: nan notifications edit " + shellArg(name) + " --enable" + } + return "see the delivery log: nan notifications deliveries " + shellArg(name) +} + +func doNotifTest(ctx context.Context, env *runsEnv, ref string) error { + existing, err := resolveChannel(ctx, env, ref, true) + if err != nil { + return err + } + res, err := env.client.TestNotificationChannel(ctx, existing.ID) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, channel: existing.Name, test: true}) + } + httpStatus := "" + if res.HTTPStatus != nil { + httpStatus = fmt.Sprintf(" (HTTP %d)", *res.HTTPStatus) + } + if notifJSON { + if err := printJSON(env.stdout, res, true); err != nil { + return err + } + } + name := runs.SanitizeLine(existing.Name) + if res.Delivered { + if !notifJSON { + fmt.Fprintf(env.stdout, "test notification delivered to %s%s\n", name, httpStatus) + } + return nil + } + code := "" + if res.Error != nil { + code = runs.SanitizeLine(*res.Error) + } + reason := runs.SanitizeLine(res.Status) + if code != "" { + reason += ", " + code + } + return exitf(exitFailed, "the test notification was not delivered to %s: %s%s. %s", name, reason, httpStatus, testFailureHint(code, existing.Name)) +} + +func doNotifRotate(ctx context.Context, env *runsEnv, ref string) error { + existing, err := resolveChannel(ctx, env, ref, true) + if err != nil { + return err + } + if existing.Kind != "" && existing.Kind != api.NotificationKindHTTPS { + return usageErrorf("only https channels have a signing secret: %s is a %s channel", runs.SanitizeLine(existing.Name), + runs.SanitizeLine(existing.Kind)) + } + out, err := env.client.RotateNotificationSigningSecret(ctx, existing.ID) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, channel: existing.Name}) + } + if notifJSON { + if err := printJSON(env.stdout, out, true); err != nil { + return err + } + } else { + fmt.Fprintf(env.stdout, "signing secret: %s\n", runs.SanitizeLine(out.SigningSecret)) + } + fmt.Fprintln(env.stderr, notifSecretNote) + if out.PreviousExpiresAt != nil { + fmt.Fprintf(env.stderr, "The previous secret keeps working until %s: requests carry both signatures until then.\n", + localTime(*out.PreviousExpiresAt)) + } + return nil +} + +func doNotifDeliveries(ctx context.Context, env *runsEnv, ref string) error { + existing, err := resolveChannel(ctx, env, ref, false) + if err != nil { + return err + } + list, err := env.client.NotificationDeliveries(ctx, existing.ID, notifDeliveriesLimit) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsRead, channel: existing.Name}) + } + if notifJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintf(env.stderr, "No deliveries in the last 30 days. Send a test with: nan notifications test %s\n", shellArg(existing.Name)) + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "WHEN\tEVENT\tSTATUS\tATTEMPT\tHTTP\tERROR\tRUN") + for _, d := range list.Data { + event := runs.SanitizeLine(d.Event) + if d.Event == "digest" { + event += fmt.Sprintf(" (%d)", d.Items) + } + httpStatus := "-" + if d.HTTPStatus != nil { + httpStatus = fmt.Sprintf("%d", *d.HTTPStatus) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\t%s\n", localTime(d.CreatedAt), event, runs.SanitizeLine(d.Status), d.Attempt, + httpStatus, orDash(d.Error, 24), orDash(d.RunID, 40)) + } + return tw.Flush() +} + +func doNotifSubs(ctx context.Context, env *runsEnv, ref string, change bool) error { + existing, err := resolveChannel(ctx, env, ref, false) + if err != nil { + return err + } + if !change { + if notifJSON { + return printJSON(env.stdout, struct { + Subscriptions []api.NotificationSubscription `json:"subscriptions"` + }{nonNilSubs(existing.Subscriptions)}, true) + } + printSubscriptions(env, existing.Subscriptions, automationNames(ctx, env, existing.Subscriptions)) + return nil + } + resolved := map[string]string{} + parse := func(arg string) (api.NotificationSubscription, error) { + event, auto, _ := parseSubscriptionArg(arg) + s := api.NotificationSubscription{Event: event} + if auto == "" { + return s, nil + } + id, ok := resolved[auto] + if !ok { + var err error + if id, err = resolveAutomation(ctx, env, auto); err != nil { + return s, err + } + resolved[auto] = id + } + s.AutomationID = &id + return s, nil + } + same := func(a, b api.NotificationSubscription) bool { + return a.Event == b.Event && ((a.AutomationID == nil && b.AutomationID == nil) || + (a.AutomationID != nil && b.AutomationID != nil && *a.AutomationID == *b.AutomationID)) + } + var subs []api.NotificationSubscription + switch { + case notifSubsClear: + subs = []api.NotificationSubscription{} + case len(notifSubsSet) > 0: + for _, a := range notifSubsSet { + s, err := parse(a) + if err != nil { + return err + } + if !slices.ContainsFunc(subs, func(x api.NotificationSubscription) bool { return same(x, s) }) { + subs = append(subs, s) + } + } + default: + subs = append([]api.NotificationSubscription{}, existing.Subscriptions...) + for _, a := range notifSubsAdd { + s, err := parse(a) + if err != nil { + return err + } + if !slices.ContainsFunc(subs, func(x api.NotificationSubscription) bool { return same(x, s) }) { + subs = append(subs, s) + } + } + for _, a := range notifSubsRemove { + s, err := parse(a) + if err != nil { + return err + } + i := slices.IndexFunc(subs, func(x api.NotificationSubscription) bool { return same(x, s) }) + if i < 0 { + return usageErrorf("%s is not subscribed to %s; see: nan notifications subscriptions %s", + runs.SanitizeLine(existing.Name), runs.Truncate(runs.SanitizeLine(a), 80), shellArg(existing.Name)) + } + subs = slices.Delete(subs, i, i+1) + } + } + c, err := env.client.PutNotificationSubscriptions(ctx, existing.ID, subs) + if err != nil { + return notifExit(err, env.cred, notifOp{scope: scopeNotificationsWrite, channel: existing.Name}) + } + if notifJSON { + return printJSON(env.stdout, c, true) + } + printSubscriptions(env, c.Subscriptions, automationNames(ctx, env, c.Subscriptions)) + return nil +} + +func nonNilSubs(s []api.NotificationSubscription) []api.NotificationSubscription { + if s == nil { + return []api.NotificationSubscription{} + } + return s +} diff --git a/cmd/notifications_test.go b/cmd/notifications_test.go new file mode 100644 index 0000000..b448331 --- /dev/null +++ b/cmd/notifications_test.go @@ -0,0 +1,699 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// Notification channels (helmcode/nan-devops +// AGENT-AUTOMATIONS-PHASE3-NOTIFICATIONS.md §4, helmcode/nan-cloud-api#349) +// against the fakeWrite platform: /v1/notification-channels with cloud-api's +// golden objects (internal/api/testdata/phase3/notification_*.json). + +const ( + chanSlackID = "c0000000-0000-4000-8000-000000000001" + chanHTTPSID = "c0000000-0000-4000-8000-000000000002" + // testHookURL is a webhook URL: a credential, never printed, never sent + // anywhere but the body of a create or a URL replace. + testHookURL = "https://hooks.slack.com/services/T0000/B0000/HOOKSECRETdoNotLeak42" + testHookHTTPS = "https://hooks.example.com/nan/HOOKSECRETdoNotLeak42" +) + +func phase3File(t testing.TB, name string) []byte { + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "phase3", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +func notifNotFound(w http.ResponseWriter, msg string) { + w.WriteHeader(404) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": msg, "type": "invalid_request_error", "code": "not_found"}}) +} + +// serveNotifications is the fake /v1/notification-channels (f.mu held). +func (f *fakeWrite) serveNotifications(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { + if f.notifOff || !f.writeAPI { + notifNotFound(w, "Not found.") + return + } + if f.channels == nil { + var list struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(phase3File(t, "notification_channels_list.json"), &list); err != nil { + t.Fatal(err) + } + f.channels = list.Data + } + writeJSON := func(status int, v any) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) + } + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notification-channels"), "/") + if rest == "" { + switch r.Method { + case http.MethodGet: + writeJSON(200, map[string]any{"object": "list", "data": f.channels}) + case http.MethodPost: + var in struct { + Name string `json:"name"` + Kind string `json:"kind"` + URL string `json:"url"` + Events *[]string `json:"events"` + AutomationID *string `json:"automation_id"` + } + _ = json.Unmarshal(body, &in) + events := []string{"approval.pending", "run.failed", "automation.disabled", "trigger.failing"} + if in.Events != nil { + events = *in.Events + } + subs := []any{} + for _, e := range events { + subs = append(subs, map[string]any{"event": e, "automation_id": in.AutomationID}) + } + host := strings.SplitN(strings.TrimPrefix(in.URL, "https://"), "/", 2)[0] + c := map[string]any{"id": "c0000000-0000-4000-8000-000000000003", "object": "notification_channel", "name": in.Name, + "kind": in.Kind, "url_hint": host + "/…/ak42", "enabled": true, "disabled_reason": nil, "consecutive_failures": 0, + "last_delivery_at": nil, "last_delivery_status": nil, "subscriptions": subs, "signing": nil, "updated_by": "cli_session", + "created_at": "2026-10-14T12:00:00Z", "updated_at": "2026-10-14T12:00:00Z"} + out := map[string]any{} + for k, v := range c { + out[k] = v + } + if in.Kind == "https" { + c["signing"] = map[string]any{"previous_expires_at": nil} + out["signing"] = c["signing"] + var g map[string]any + _ = json.Unmarshal(phase3File(t, "notification_channel_create_https.json"), &g) + out["signing_secret"] = g["signing_secret"] + } + f.channels = append(f.channels, c) + writeJSON(201, out) + } + return + } + parts := strings.SplitN(rest, "/", 2) + var ch map[string]any + idx := -1 + for i, c := range f.channels { + if c["id"] == parts[0] { + ch, idx = c, i + } + } + if ch == nil { + notifNotFound(w, "notification channel not found") + return + } + sub := "" + if len(parts) == 2 { + sub = parts[1] + } + switch r.Method + " " + sub { + case "GET ": + writeJSON(200, ch) + case "PATCH ": + var in map[string]any + _ = json.Unmarshal(body, &in) + if v, ok := in["name"]; ok { + ch["name"] = v + } + if v, ok := in["enabled"]; ok { + ch["enabled"] = v + if v == true { + ch["disabled_reason"], ch["consecutive_failures"] = nil, 0 + } else { + ch["disabled_reason"] = "member" + } + } + if _, ok := in["url"]; ok { + ch["consecutive_failures"] = 0 + if ch["disabled_reason"] != "member" { + ch["enabled"], ch["disabled_reason"] = true, nil + } + } + writeJSON(200, ch) + case "DELETE ": + f.channels = append(f.channels[:idx], f.channels[idx+1:]...) + w.WriteHeader(204) + case "PUT subscriptions": + var in struct { + Subscriptions []any `json:"subscriptions"` + } + _ = json.Unmarshal(body, &in) + if in.Subscriptions == nil { + in.Subscriptions = []any{} + } + ch["subscriptions"] = in.Subscriptions + writeJSON(200, ch) + case "POST test": + if ch["enabled"] != true { + w.WriteHeader(409) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "the channel is disabled; enable it first", + "type": "invalid_request_error", "code": "channel_disabled"}}) + return + } + writeJSON(200, map[string]any{"delivered": true, "status": "delivered", "http_status": 200, "error": nil}) + case "POST rotate-signing-secret": + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(200) + _, _ = w.Write(phase3File(t, "notification_rotate.json")) + case "GET deliveries": + w.WriteHeader(200) + _, _ = w.Write(phase3File(t, "notification_deliveries.json")) + default: + notifNotFound(w, "Not found.") + } +} + +func v1Err(status int, code, param, msg string) string { + b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": msg, "type": "invalid_request_error", "param": param, "code": code}}) + return string(b) +} + +// noHookLeak checks the webhook URL is only in the body of the request +// that had to carry it. +func noHookLeak(t *testing.T, r writeRun, fake *fakeWrite) { + t.Helper() + for what, text := range map[string]string{"stdout": r.stdout, "stderr": r.stderr, "message": r.msg} { + if strings.Contains(text, "HOOKSECRET") { + t.Errorf("%s carries the webhook URL: %q", what, text) + } + } + for _, req := range fake.requests() { + carries := (req.Method == http.MethodPost && req.Path == "/v1/notification-channels") || req.Method == http.MethodPatch + if !carries && strings.Contains(req.Body+req.Path, "HOOKSECRET") { + t.Errorf("%s %s carries the webhook URL", req.Method, req.Path) + } + } + _ = filepath.Walk(r.home, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + if b, _ := os.ReadFile(path); strings.Contains(string(b), "HOOKSECRET") { + t.Errorf("%s holds the webhook URL", path) + } + } + return nil + }) +} + +func mustExit(t *testing.T, r writeRun, code int, wants ...string) { + t.Helper() + if r.code != code { + t.Fatalf("exit %d (want %d): %s\nstdout: %s\nstderr: %s", r.code, code, r.msg, r.stdout, r.stderr) + } + wantAll(t, "message", r.msg, wants...) +} + +func TestNotificationsLs(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "ls") + mustExit(t, r, 0) + checkGolden(t, "notifications/ls.golden", r.stdout) + wantAll(t, "stderr", r.stderr, "ci receiver", "nan notifications edit", "--enable") + if len(fake.sent("GET", "/api/auth/me")) != 1 || len(fake.sent("GET", "/v1/notification-channels")) != 1 { + t.Errorf("requests %v", fake.requests()) + } + + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "ls", "--json") + mustExit(t, r, 0) + var out struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(r.stdout), &out); err != nil || len(out.Data) != 2 || out.Data[0]["url_hint"] != "hooks.slack.com/…/abcd" { + t.Errorf("%v %s", err, r.stdout) + } + + fake = newFakeWrite() + fake.channels = []map[string]any{} + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "ls") + mustExit(t, r, 0) + if r.stdout != "" { + t.Errorf("stdout %q", r.stdout) + } + wantAll(t, "stderr", r.stderr, "No notification channels yet", "nan notifications add") +} + +func TestNotificationsShow(t *testing.T) { + fake := newFakeWrite() + // Names are unique whatever the case: a different case still finds it. + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", "CI Receiver") + mustExit(t, r, 0) + checkGolden(t, "notifications/show_failing.golden", r.stdout) + + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", chanSlackID) + mustExit(t, r, 0) + checkGolden(t, "notifications/show_slack.golden", r.stdout) + + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", "team alerts", "--json") + mustExit(t, r, 0) + if !strings.Contains(r.stdout, `"kind": "slack"`) || strings.Contains(r.stdout, "signing_secret") { + t.Errorf("%s", r.stdout) + } + + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", "nope") + mustExit(t, r, exitUsage, "no notification channel named", "nan notifications ls") +} + +func TestNotificationsAddSlackFromAFile(t *testing.T) { + fake := newFakeWrite() + file := writeValueFile(t, testHookURL+"\n") + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "add", "deploys", "--kind", "slack", "--url-file", file) + mustExit(t, r, 0) + posts := fake.sent("POST", "/v1/notification-channels") + if len(posts) != 1 || posts[0].Body != `{"name":"deploys","kind":"slack","url":"`+testHookURL+`"}` { + t.Fatalf("posts %v", posts) + } + if posts[0].Cookie != "nan_session=sess-cli" { + t.Errorf("cookie %q", posts[0].Cookie) + } + checkGolden(t, "notifications/add_slack.golden", r.stdout) + wantAll(t, "stderr", r.stderr, "nan notifications test deploys") + noHookLeak(t, r, fake) +} + +func TestNotificationsAddHTTPSPrintsTheSecretOnce(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession, stdin: testHookHTTPS + "\n"}, + "notifications", "add", "my receiver", "--kind", "https", "--url-stdin", + "--event", "run.finished,run.failed", "--event", "approval.pending", "--automation", "PR review api") + mustExit(t, r, 0) + posts := fake.sent("POST", "/v1/notification-channels") + want := `{"name":"my receiver","kind":"https","url":"` + testHookHTTPS + `","events":["run.finished","run.failed","approval.pending"],"automation_id":"` + testAutoID + `"}` + if len(posts) != 1 || posts[0].Body != want { + t.Fatalf("posts %v", posts) + } + checkGolden(t, "notifications/add_https.golden", r.stdout) + if n := strings.Count(r.stdout, "nan_nsec_SHOWNONCE"); n != 1 { + t.Errorf("secret printed %d times", n) + } + if strings.Contains(r.stderr, "nan_nsec_") { + t.Error("secret on stderr") + } + wantAll(t, "stderr", r.stderr, notifSecretNote) + noHookLeak(t, r, fake) + + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession, stdin: testHookHTTPS}, + "notifications", "add", "my receiver", "--kind", "https", "--url-stdin", "--no-events", "--json") + mustExit(t, r, 0) + var out map[string]any + if err := json.Unmarshal([]byte(r.stdout), &out); err != nil || !strings.HasPrefix(out["signing_secret"].(string), "nan_nsec_") { + t.Errorf("%v %s", err, r.stdout) + } + if b := fake.sent("POST", "/v1/notification-channels")[0].Body; !strings.Contains(b, `"events":[]`) { + t.Errorf("body %s", b) + } + wantAll(t, "stderr", r.stderr, notifSecretNote) + noHookLeak(t, r, fake) +} + +// The URL is a credential: never an argument, never --url. +func TestNotificationsURLNeverOnTheCommandLine(t *testing.T) { + file := writeValueFile(t, testHookURL) + for _, c := range []struct { + args []string + wants []string + }{ + {[]string{"add", "x", "--kind", "slack", testHookURL}, []string{"--url-file", "shell history"}}, + {[]string{"add", "x", "--kind", "slack", "--url", testHookURL}, []string{"--url-file", "--url-stdin", "shell history"}}, + {[]string{"add", "x", "--kind", "slack", "--url=" + testHookURL}, []string{"--url-file", "shell history"}}, + {[]string{"edit", "team alerts", "--url", testHookURL}, []string{"--url-file", "shell history"}}, + {[]string{"edit", "team alerts", testHookURL}, []string{"shell history"}}, + {[]string{"add", "x", "--kind", "slack"}, []string{"--url-file PATH or --url-stdin"}}, + {[]string{"add", "x", "--kind", "slack", "--url-file", file, "--url-stdin"}, []string{"not both"}}, + {[]string{"add", "x", "--url-file", file}, []string{"--kind", "slack, teams, discord or https"}}, + {[]string{"add", "x", "--kind", "email", "--url-file", file}, []string{"slack, teams, discord or https"}}, + {[]string{"add", "x", "--kind", "slack", "--url-file", file, "--event", "run.started"}, []string{"run.started", "approval.pending"}}, + {[]string{"add", "x", "--kind", "slack", "--url-file", file, "--event", "run.failed", "--no-events"}, []string{"--no-events"}}, + {[]string{"add"}, []string{"name"}}, + } { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"notifications"}, c.args...)...) + mustExit(t, r, exitUsage, c.wants...) + for _, req := range fake.requests() { + if req.Method != http.MethodGet { + t.Errorf("%v sent %s %s", c.args, req.Method, req.Path) + } + } + noHookLeak(t, r, fake) + } + // --url-stdin from a terminal would echo the URL. + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession, tty: true}, "notifications", "add", "x", "--kind", "slack", "--url-stdin") + mustExit(t, r, exitUsage, "stdin is a terminal") +} + +func TestNotificationsEdit(t *testing.T) { + fake := newFakeWrite() + file := writeValueFile(t, testHookHTTPS+"\n") + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "edit", "ci receiver", "--url-file", file, "--enable", "--name", "ci") + mustExit(t, r, 0) + p := fake.sent("PATCH", "/v1/notification-channels/"+chanHTTPSID) + if len(p) != 1 || p[0].Body != `{"name":"ci","enabled":true,"url":"`+testHookHTTPS+`"}` { + t.Fatalf("patch %v", p) + } + wantAll(t, "stdout", r.stdout, "notification channel ci updated: on") + noHookLeak(t, r, fake) + + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "edit", "team alerts", "--disable", "--json") + mustExit(t, r, 0) + if !strings.Contains(r.stdout, `"disabled_reason": "member"`) { + t.Errorf("%s", r.stdout) + } + if p := fake.sent("PATCH", "/v1/notification-channels/"+chanSlackID); len(p) != 1 || p[0].Body != `{"enabled":false}` { + t.Errorf("patch %v", p) + } + + for args, want := range map[string]string{ + chanSlackID: "nothing to change", + chanSlackID + " --enable --disable": "--enable or --disable", + chanSlackID + " --kind teams": "unknown flag", + chanSlackID + " --name ": "name", + } { + r = runWrite(t, newFakeWrite(), writeSetup{session: cliSession}, append([]string{"notifications", "edit"}, splitArgs(args)...)...) + mustExit(t, r, exitUsage, want) + } +} + +// splitArgs splits on spaces, keeping a trailing empty value for "--name ". +func splitArgs(s string) []string { + if strings.HasSuffix(s, " ") { + return append(strings.Fields(s), "") + } + return strings.Fields(s) +} + +func TestNotificationsRm(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "rm", "team alerts") + mustExit(t, r, exitUsage, "--yes") + if len(fake.sent("DELETE", "/v1/notification-channels/"+chanSlackID)) != 0 { + t.Fatal("deleted without confirmation") + } + r = runWrite(t, fake, writeSetup{session: cliSession, tty: true, stdin: "n\n"}, "notifications", "rm", "team alerts") + mustExit(t, r, exitFailed, "cancelled") + r = runWrite(t, fake, writeSetup{session: cliSession, tty: true, stdin: "y\n"}, "notifications", "rm", "team alerts") + mustExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "delete notification channel team alerts?") + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "delete", "ci receiver", "--yes") + mustExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "notification channel ci receiver deleted") + if len(fake.sent("DELETE", "/v1/notification-channels/"+chanSlackID)) != 1 || len(fake.sent("DELETE", "/v1/notification-channels/"+chanHTTPSID)) != 1 { + t.Errorf("%v", fake.requests()) + } +} + +func TestNotificationsTest(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "test", "team alerts") + mustExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "test notification delivered to team alerts (HTTP 200)") + + // A disabled channel: the platform refuses, the CLI says how to enable it. + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "test", "ci receiver") + mustExit(t, r, exitUsage, "ci receiver is disabled", "nan notifications edit 'ci receiver' --enable") + + fake = newFakeWrite() + fake.failOn("POST /v1/notification-channels/"+chanSlackID+"/test", 200, string(phase3File(t, "notification_test_failed.json"))) + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "test", "team alerts") + mustExit(t, r, exitFailed, "not delivered", "HTTP 404", "refused", "--url-file") + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "test", "team alerts", "--json") + mustExit(t, r, exitFailed) + assertOneJSONDocument(t, r.stdout) + if !strings.Contains(r.stdout, `"error": "http_4xx"`) { + t.Errorf("%s", r.stdout) + } + + fake = newFakeWrite() + fake.failOn("POST /v1/notification-channels/"+chanSlackID+"/test", 429, + `{"error":{"message":"too many notification requests recently; try again later","type":"rate_limit_exceeded","code":"rate_limited"}}`) + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "test", "team alerts") + mustExit(t, r, exitUnavailable, "too many test notifications") +} + +func TestNotificationTestHints(t *testing.T) { + for code, want := range map[string]string{ + "timeout": "did not answer", "connect": "did not answer", "tls": "TLS", "dns": "does not resolve", + "blocked_address": "does not send to", "invalid_url": "does not send to", "redirect": "redirect", + "http_4xx": "refused", "http_5xx": "error", "rate_limited": "budget", "channel_disabled": "--enable", + } { + if h := testFailureHint(code, "x"); !strings.Contains(h, want) { + t.Errorf("%s: %q lacks %q", code, h, want) + } + } +} + +func TestNotificationsRotate(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "rotate", "ci receiver") + mustExit(t, r, 0) + checkGolden(t, "notifications/rotate.golden", r.stdout) + wantAll(t, "stderr", r.stderr, notifSecretNote, "previous secret keeps working until 2026-10-15 12:00") + if strings.Contains(r.stderr, "nan_nsec_") { + t.Error("secret on stderr") + } + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "rotate", "ci receiver", "--json") + mustExit(t, r, 0) + if !strings.Contains(r.stdout, `"signing_secret": "nan_nsec_ROTATED`) { + t.Errorf("%s", r.stdout) + } + wantAll(t, "stderr", r.stderr, notifSecretNote) + + // A slack channel has no signing secret: refused before anything is sent. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "rotate", "team alerts") + mustExit(t, r, exitUsage, "only https channels have a signing secret", "team alerts is a slack channel") + if len(fake.sent("POST", "/v1/notification-channels/"+chanSlackID+"/rotate-signing-secret")) != 0 { + t.Error("sent the rotate") + } + // The platform's own refusal reads the same. + fake = newFakeWrite() + fake.failOn("POST /v1/notification-channels/"+chanHTTPSID+"/rotate-signing-secret", 409, + v1Err(409, "signing_not_supported", "", "only https channels have a signing secret")) + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "rotate", "ci receiver") + mustExit(t, r, exitUsage, "only https channels have a signing secret") +} + +func TestNotificationsDeliveries(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "deliveries", "ci receiver", "--limit", "5") + mustExit(t, r, 0) + checkGolden(t, "notifications/deliveries.golden", r.stdout) + reqs := fake.requests() + last := reqs[len(reqs)-1] + if last.Path != "/v1/notification-channels/"+chanHTTPSID+"/deliveries" { + t.Errorf("%v", last) + } + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "deliveries", "ci receiver", "--json") + mustExit(t, r, 0) + assertOneJSONDocument(t, r.stdout) + for _, l := range []string{"0", "101", "x"} { + r = runWrite(t, newFakeWrite(), writeSetup{session: cliSession}, "notifications", "deliveries", "ci receiver", "--limit", l) + mustExit(t, r, exitUsage, "--limit") + } +} + +func TestNotificationsDeliveriesSendsTheLimit(t *testing.T) { + var got string + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "deliveries", "team alerts", "--limit", "7") + mustExit(t, r, 0) + for _, req := range fake.requests() { + if strings.HasSuffix(req.Path, "/deliveries") { + got = req.Path + } + } + if got == "" { + t.Fatal("no deliveries request") + } +} + +func TestNotificationsSubscriptions(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "subscriptions", "ci receiver") + mustExit(t, r, 0) + checkGolden(t, "notifications/subscriptions.golden", r.stdout) + if len(fake.sent("PUT", "/v1/notification-channels/"+chanHTTPSID+"/subscriptions")) != 0 { + t.Error("listing wrote") + } + + put := func(f *fakeWrite, id string) string { + t.Helper() + p := f.sent("PUT", "/v1/notification-channels/"+id+"/subscriptions") + if len(p) != 1 { + t.Fatalf("puts %v", f.requests()) + } + return p[0].Body + } + for _, c := range []struct { + args []string + id string + want string + }{ + {[]string{"team alerts", "--add", "run.finished=PR review api"}, chanSlackID, + `{"subscriptions":[{"event":"approval.pending","automation_id":null},{"event":"run.failed","automation_id":null},{"event":"automation.disabled","automation_id":null},{"event":"trigger.failing","automation_id":null},{"event":"run.finished","automation_id":"` + testAutoID + `"}]}`}, + {[]string{"team alerts", "--remove", "run.failed", "--remove", "trigger.failing"}, chanSlackID, + `{"subscriptions":[{"event":"approval.pending","automation_id":null},{"event":"automation.disabled","automation_id":null}]}`}, + {[]string{"team alerts", "--add", "run.failed"}, chanSlackID, + `{"subscriptions":[{"event":"approval.pending","automation_id":null},{"event":"run.failed","automation_id":null},{"event":"automation.disabled","automation_id":null},{"event":"trigger.failing","automation_id":null}]}`}, + {[]string{"ci receiver", "--set", "run.failed", "--set", "approval.pending=" + testAutoID}, chanHTTPSID, + `{"subscriptions":[{"event":"run.failed","automation_id":null},{"event":"approval.pending","automation_id":"` + testAutoID + `"}]}`}, + {[]string{"ci receiver", "--remove", "run.finished=PR review api"}, chanHTTPSID, `{"subscriptions":[]}`}, + {[]string{"ci receiver", "--clear"}, chanHTTPSID, `{"subscriptions":[]}`}, + } { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"notifications", "subscriptions"}, c.args...)...) + mustExit(t, r, 0) + if got := put(fake, c.id); got != c.want { + t.Errorf("%v:\n got %s\nwant %s", c.args, got, c.want) + } + if r.stdout == "" { + t.Errorf("%v printed nothing", c.args) + } + } + + r = runWrite(t, newFakeWrite(), writeSetup{session: cliSession}, "notifications", "subscriptions", "ci receiver", "--clear", "--json") + mustExit(t, r, 0) + if !strings.Contains(r.stdout, `"subscriptions": []`) { + t.Errorf("%s", r.stdout) + } + + for _, c := range []struct { + args []string + want string + }{ + {[]string{"--set", "run.failed", "--add", "run.finished"}, "--set"}, + {[]string{"--clear", "--remove", "run.failed"}, "--clear"}, + {[]string{"--add", "run.started"}, "run.started"}, + {[]string{"--add", "=PR review api"}, "EVENT"}, + {[]string{"--remove", "approval.pending=PR review api"}, "not subscribed"}, + {[]string{"--add", "run.failed=nope"}, "no automation named"}, + } { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"notifications", "subscriptions", "team alerts"}, c.args...)...) + mustExit(t, r, exitUsage, c.want) + if len(fake.sent("PUT", "/v1/notification-channels/"+chanSlackID+"/subscriptions")) != 0 { + t.Errorf("%v wrote", c.args) + } + } +} + +// The refusals of the platform, each with what to do next. +func TestNotificationsErrors(t *testing.T) { + file := writeValueFile(t, testHookURL) + add := []string{"notifications", "add", "deploys", "--kind", "slack", "--url-file", file} + for _, c := range []struct { + status int + body string + code int + wants []string + }{ + {400, v1Err(400, "invalid_url", "url", "the URL is not valid"), exitUsage, []string{"the URL was refused: the URL is not valid", "hooks.slack.com/services/"}}, + {400, v1Err(400, "invalid_url", "url", "the URL's host must resolve only to public addresses"), exitUsage, []string{"public addresses"}}, + {409, v1Err(409, "name_taken", "name", "a notification channel with this name already exists"), exitUsage, []string{"already taken", "notification channels"}}, + {409, v1Err(409, "too_many_channels", "", "a member can have at most 10 notification channels"), exitUsage, []string{"10 notification channels", "nan notifications rm"}}, + {404, v1Err(404, "automation_not_found", "automation_id", "automation not found"), exitUsage, []string{"automation", "nan automations ls"}}, + {429, `{"error":{"message":"Too many notification channel changes recently. Try again later.","type":"rate_limit_exceeded","code":"rate_limited"}}`, exitUnavailable, []string{"too many changes to your notification channels"}}, + {503, `{"error":{"message":"notifications are temporarily unavailable; try again","type":"server_error","code":"notifications_unavailable"}}`, exitUnavailable, []string{"temporarily unavailable"}}, + {403, `{"error":{"message":"this token lacks the notifications:write scope","type":"permission_error","code":"token_scope_denied"}}`, exitAuth, []string{"notifications:write", "Settings > Tokens"}}, + {403, `{"error":{"message":"x","type":"permission_error","code":"credential_not_allowed"}}`, exitAuth, []string{"nan auth login"}}, + } { + fake := newFakeWrite() + fake.failOn("POST /v1/notification-channels", c.status, c.body) + r := runWrite(t, fake, writeSetup{session: cliSession}, add...) + mustExit(t, r, c.code, c.wants...) + noHookLeak(t, r, fake) + } +} + +// Without the feature, every command says where things stand and exits 64. +func TestNotificationsFeatureOff(t *testing.T) { + file := writeValueFile(t, testHookURL) + cmds := [][]string{ + {"ls"}, {"show", "team alerts"}, {"add", "x", "--kind", "slack", "--url-file", file}, {"edit", "team alerts", "--enable"}, + {"rm", "team alerts", "--yes"}, {"test", "team alerts"}, {"rotate", "ci receiver"}, {"deliveries", "team alerts"}, + {"subscriptions", "team alerts"}, + } + for _, args := range cmds { + // A session asks /api/auth/me. + fake := newFakeWrite() + fake.notifOff = true + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"notifications"}, args...)...) + mustExit(t, r, exitUsage, "not available for your account", "Settings > Notifications", "https://nan.builders/docs/notifications") + if len(fake.sent("POST", "/v1/notification-channels")) != 0 { + t.Errorf("%v sent a write", args) + } + // A token has no /api/auth/me: the platform's 404 says it. + fake = newFakeWrite() + fake.notifOff = true + r = runWrite(t, fake, writeSetup{env: "nan_pat_" + strings.Repeat("a", 43)}, append([]string{"notifications"}, args...)...) + mustExit(t, r, exitUsage, "not available for your account") + noHookLeak(t, r, fake) + } + // The write API off: the CLI cannot manage channels, the portal can. + fake := newFakeWrite() + fake.writeAPI = false + r := runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "ls") + mustExit(t, r, exitUsage, "from the CLI is not available for your account yet", "portal") +} + +// A token with notifications:write only cannot list: an id still works. +func TestNotificationsWriteOnlyToken(t *testing.T) { + tok := "nan_pat_" + strings.Repeat("b", 43) + scope := `{"error":{"message":"this token lacks the notifications:read scope","type":"permission_error","code":"token_scope_denied"}}` + fake := newFakeWrite() + fake.failOn("GET /v1/notification-channels", 403, scope) + r := runWrite(t, fake, writeSetup{env: tok}, "notifications", "edit", chanSlackID, "--disable") + mustExit(t, r, 0) + if p := fake.sent("PATCH", "/v1/notification-channels/"+chanSlackID); len(p) != 1 || p[0].Authz != "Bearer "+tok { + t.Errorf("%v", fake.requests()) + } + if len(fake.sent("GET", "/api/auth/me")) != 0 { + t.Error("a token asked /api/auth/me") + } + r = runWrite(t, fake, writeSetup{env: tok}, "notifications", "rm", chanSlackID, "--yes") + mustExit(t, r, 0) + r = runWrite(t, fake, writeSetup{env: tok}, "notifications", "edit", "team alerts", "--disable") + mustExit(t, r, exitAuth, "notifications:read", "channel id") + + // An unknown id with such a token: not found. + fake = newFakeWrite() + fake.failOn("GET /v1/notification-channels", 403, scope) + r = runWrite(t, fake, writeSetup{env: tok}, "notifications", "test", "c0000000-0000-4000-8000-0000000000ff") + mustExit(t, r, exitUsage, "no notification channel", "c0000000-0000-4000-8000-0000000000ff") +} + +func TestNotificationsHelp(t *testing.T) { + help := notificationsCmd.Long + for _, want := range []string{"notifications:read", "notifications:write", "--url-file", "--url-stdin", "shell history", + "https://nan.builders/docs/notifications", "Settings > Notifications", "approval.pending", "run.finished"} { + if !strings.Contains(help, want) { + t.Errorf("nan notifications --help lacks %q", want) + } + } + var all strings.Builder + all.WriteString(help) + for _, c := range notificationsCmd.Commands() { + all.WriteString(c.Long + c.Short + c.Example) + if c.InheritedFlags().Lookup("token-file") == nil { + t.Errorf("nan notifications %s has no --token-file", c.Name()) + } + } + if strings.Contains(all.String(), "—") { + t.Error("an em-dash in the notifications help") + } +} + +func TestNotificationsUnknownVerbs(t *testing.T) { + for verb, want := range map[string]string{"create": "add", "list": "ls", "rotate-secret": "rotate", "remove-me": "unknown command"} { + r := runWrite(t, newFakeWrite(), writeSetup{session: cliSession}, "notifications", verb) + mustExit(t, r, exitUsage, want) + } + _ = io.Discard +} diff --git a/cmd/testdata/notifications/add_https.golden b/cmd/testdata/notifications/add_https.golden new file mode 100644 index 0000000..673878f --- /dev/null +++ b/cmd/testdata/notifications/add_https.golden @@ -0,0 +1,2 @@ +notification channel my receiver added: https, hooks.example.com/…/ak42, 3 events, 1 automation +signing secret: nan_nsec_SHOWNONCEshownonceSHOWNONCEshownonce123 diff --git a/cmd/testdata/notifications/add_slack.golden b/cmd/testdata/notifications/add_slack.golden new file mode 100644 index 0000000..e023e15 --- /dev/null +++ b/cmd/testdata/notifications/add_slack.golden @@ -0,0 +1 @@ +notification channel deploys added: slack, hooks.slack.com/…/ak42, 4 events, all automations diff --git a/cmd/testdata/notifications/deliveries.golden b/cmd/testdata/notifications/deliveries.golden new file mode 100644 index 0000000..97a57d7 --- /dev/null +++ b/cmd/testdata/notifications/deliveries.golden @@ -0,0 +1,4 @@ +WHEN EVENT STATUS ATTEMPT HTTP ERROR RUN +2026-10-14 11:30 digest (12) failed 1 404 http_4xx - +2026-10-14 10:05 test delivered 1 200 - - +2026-10-14 10:00 run.failed retrying 2 - timeout 6f1c2a9b-0000-4000-8000-000000000001 diff --git a/cmd/testdata/notifications/ls.golden b/cmd/testdata/notifications/ls.golden new file mode 100644 index 0000000..9ad07b3 --- /dev/null +++ b/cmd/testdata/notifications/ls.golden @@ -0,0 +1,3 @@ +NAME KIND DESTINATION STATUS EVENTS LAST DELIVERY CHANGED FROM +team alerts slack hooks.slack.com/…/abcd on 4 events, all automations 2026-10-14 10:00 delivered Browser +ci receiver https hooks.example.com off: failing 1 event, 1 automation 2026-10-14 11:30 failed CLI diff --git a/cmd/testdata/notifications/rotate.golden b/cmd/testdata/notifications/rotate.golden new file mode 100644 index 0000000..7db8caa --- /dev/null +++ b/cmd/testdata/notifications/rotate.golden @@ -0,0 +1 @@ +signing secret: nan_nsec_ROTATEDrotatedROTATEDrotatedROTATED12345 diff --git a/cmd/testdata/notifications/show_failing.golden b/cmd/testdata/notifications/show_failing.golden new file mode 100644 index 0000000..59b35e4 --- /dev/null +++ b/cmd/testdata/notifications/show_failing.golden @@ -0,0 +1,14 @@ +name ci receiver +id c0000000-0000-4000-8000-000000000002 +kind https +destination hooks.example.com +status off: failing +why turned off after 20 failed deliveries in a row; fix the receiver (or replace the URL with --url-file) and turn it on with: nan notifications edit 'ci receiver' --enable +last delivery 2026-10-14 11:30 failed +signing two secrets: the previous one works until 2026-10-15 09:00 +last changed from CLI +created 2026-10-13 09:00 +updated 2026-10-14 09:00 + +EVENT AUTOMATION +run.finished PR review api (a0000000-0000-4000-8000-000000000001) diff --git a/cmd/testdata/notifications/show_slack.golden b/cmd/testdata/notifications/show_slack.golden new file mode 100644 index 0000000..ce53c54 --- /dev/null +++ b/cmd/testdata/notifications/show_slack.golden @@ -0,0 +1,15 @@ +name team alerts +id c0000000-0000-4000-8000-000000000001 +kind slack +destination hooks.slack.com/…/abcd +status on +last delivery 2026-10-14 10:00 delivered +last changed from Browser +created 2026-10-14 09:00 +updated 2026-10-14 09:00 + +EVENT AUTOMATION +approval.pending all automations +run.failed all automations +automation.disabled all automations +trigger.failing all automations diff --git a/cmd/testdata/notifications/subscriptions.golden b/cmd/testdata/notifications/subscriptions.golden new file mode 100644 index 0000000..7abbdf6 --- /dev/null +++ b/cmd/testdata/notifications/subscriptions.golden @@ -0,0 +1,2 @@ +EVENT AUTOMATION +run.finished PR review api (a0000000-0000-4000-8000-000000000001) diff --git a/cmd/writeapi_test.go b/cmd/writeapi_test.go index e304422..4dbfcae 100644 --- a/cmd/writeapi_test.go +++ b/cmd/writeapi_test.go @@ -51,6 +51,11 @@ type fakeWrite struct { dropFirstCreate bool dropped bool automationPatch map[string]any + // Notification channels (notifications_test.go): notifOff answers + // features.notifications=false and 404 on every channel route; + // channels nil loads cloud-api's golden list. + notifOff bool + channels []map[string]any } func newFakeWrite() *fakeWrite { @@ -102,6 +107,10 @@ func (f *fakeWrite) handler(t *testing.T) http.Handler { } route := r.Method + " " + r.URL.Path if fe, ok := f.fail[route]; ok { + if fe.status == 204 { + w.WriteHeader(204) + return + } w.WriteHeader(fe.status) _, _ = io.WriteString(w, fe.body) return @@ -111,9 +120,13 @@ func (f *fakeWrite) handler(t *testing.T) http.Handler { _ = json.NewEncoder(w).Encode(v) } const triggerID = "b0000000-0000-4000-8000-000000000002" + if strings.HasPrefix(r.URL.Path, "/v1/notification-channels") { + f.serveNotifications(t, w, r, body) + return + } switch { case route == "GET /api/auth/me": - writeJSON(200, map[string]any{"features": map[string]bool{"write_api": f.writeAPI, "secrets": true, "automations": true}}) + writeJSON(200, map[string]any{"features": map[string]bool{"write_api": f.writeAPI, "secrets": true, "automations": true, "notifications": !f.notifOff}}) case route == "GET /api/account/secrets", route == "GET /v1/secrets": if route == "GET /v1/secrets" && !f.writeAPI { writeJSON(404, map[string]any{"error": map[string]any{"message": "Not found", "type": "invalid_request_error", "code": "not_found"}}) @@ -276,6 +289,12 @@ const triggerUUID = "b0000000-0000-4000-8000-000000000002" // unchanged, so one execute does not leak into the next. func resetCommandFlags(c *cobra.Command) { reset := func(f *pflag.Flag) { + if sv, ok := f.Value.(pflag.SliceValue); ok { + // Set("[]") would append a literal "[]" to a slice flag. + _ = sv.Replace(nil) + f.Changed = false + return + } _ = f.Value.Set(f.DefValue) f.Changed = false } From 5818660f0d121e343fd783359ea6fea0f9d8e4b6 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:48:08 +0200 Subject: [PATCH 3/6] docs(readme): notifications; bump version to 0.1.34 Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++++++++++++++++++++++++++++++ internal/tui/tui.go | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dc867d9..fa9d30a 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,37 @@ without `workspaces:read` (or `automations:read`, for automation names in `nan approvals ls` and `nan runs show`) gets the ids instead, never an error. `--json` prints the API's objects unchanged, with ids only. +### Notifications + +Get told in Slack, Microsoft Teams, Discord or your own HTTPS endpoint when an +automation run waits for approval, fails or finishes, when an automation is +disabled, or when a webhook trigger keeps failing: + +```bash +nan notifications ls [--json] +nan notifications show [--json] +nan notifications add NAME --kind slack|teams|discord|https (--url-file PATH | --url-stdin) + [--event EVENT]... [--no-events] [--automation ] [--json] +nan notifications edit [--name NEW] [--enable | --disable] [--url-file PATH | --url-stdin] [--json] +nan notifications rm [--yes] +nan notifications test [--json] # exit 1 when not delivered +nan notifications rotate [--json] # https only: new signing secret, shown ONCE +nan notifications deliveries [--limit 20] [--json] +nan notifications subscriptions [--set|--add|--remove EVENT[=AUTOMATION]]... [--clear] [--json] +``` + +A webhook URL is a credential (whoever has it can post to your channel): it +comes from `--url-file` or a pipe into `--url-stdin`, never from the command +line, and NaN never shows it again (only the host and its last characters). +An `https` channel's signing secret is printed once, to stdout (inside the +JSON with `--json`); requests are signed like NaN's inbound webhooks. A +channel turns itself off after 20 failed deliveries in a row: fix the +receiver and turn it back on with `nan notifications edit NAME --enable`. +A platform token needs `notifications:read` to read and +`notifications:write` to change, added in the portal only. Without +notifications on your account, these commands exit 64 and point at the +portal. Docs: . + ### Exit codes `nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 71c30dd..581fae1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3661,7 +3661,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.33" +const Version = "0.1.34" func renderAbout(l layout) string { var b strings.Builder From 4db67d50d451028d92942d5c811c79df6badb20a Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:49:01 +0200 Subject: [PATCH 4/6] fix(notifications): URL length limit and URL wording in input errors Co-Authored-By: Claude Opus 5.5 --- cmd/notifications.go | 11 +++++++++-- cmd/notifications_test.go | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/notifications.go b/cmd/notifications.go index 0775c26..c2794ef 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -34,6 +34,9 @@ const ( scopeNotificationsRead = "notifications:read" scopeNotificationsWrite = "notifications:write" + // maxHookURL is the platform's limit on a channel URL. + maxHookURL = 2048 + // maxDeliveriesLimit is the platform's cap on ?limit=. maxDeliveriesLimit = 100 ) @@ -472,13 +475,17 @@ func readHookURL() (string, error) { if err != nil { var exit *ExitError if errors.As(err, &exit) { - exit.Err = errors.New(strings.Replace(exit.Err.Error(), "the value", "the URL", 1)) + msg := strings.ReplaceAll(exit.Err.Error(), "the value", "the URL") + exit.Err = errors.New(strings.ReplaceAll(msg, ", the most a secret holds", "")) } return "", err } u = strings.TrimSpace(u) - if u == "" { + switch { + case u == "": return "", usageErrorf("the URL is empty") + case len(u) > maxHookURL: + return "", usageErrorf("the URL is over %d bytes, the most the platform takes", maxHookURL) } return u, nil } diff --git a/cmd/notifications_test.go b/cmd/notifications_test.go index b448331..274c527 100644 --- a/cmd/notifications_test.go +++ b/cmd/notifications_test.go @@ -343,6 +343,19 @@ func TestNotificationsURLNeverOnTheCommandLine(t *testing.T) { } noHookLeak(t, r, fake) } + // Too long or empty: refused before anything is sent, in the URL's words. + for in, want := range map[string]string{"https://x.example/" + strings.Repeat("a", 2100): "over 2048 bytes", "\n": "the URL is empty", + strings.Repeat("a", 20000): "the URL is over 16 KiB"} { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession, stdin: in}, "notifications", "add", "x", "--kind", "https", "--url-stdin") + mustExit(t, r, exitUsage, want) + if strings.Contains(r.msg, "value") || strings.Contains(r.msg, "secret") { + t.Errorf("message speaks of a secret value: %s", r.msg) + } + if len(fake.sent("POST", "/v1/notification-channels")) != 0 { + t.Error("sent it") + } + } // --url-stdin from a terminal would echo the URL. fake := newFakeWrite() r := runWrite(t, fake, writeSetup{session: cliSession, tty: true}, "notifications", "add", "x", "--kind", "slack", "--url-stdin") From 239ef5298313546197e1be2a71ed9277b91e6ca8 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:52:53 +0200 Subject: [PATCH 5/6] fix(notifications): QA review round 1 (deliveries limit asserted on the wire, no dead code, automation ids compared in lower case, subscriptions --json documented) Co-Authored-By: Claude Opus 5.5 --- cmd/notifications.go | 5 +++++ cmd/notifications_test.go | 23 ++++++++++++++++------- cmd/writeapi_test.go | 3 ++- internal/api/notifications.go | 6 ------ internal/api/notifications_test.go | 2 -- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/cmd/notifications.go b/cmd/notifications.go index c2794ef..0e449b3 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -336,6 +336,9 @@ EVENT alone is for every automation; EVENT=AUTOMATION (a name or an id) for that one only. Events: approval.pending, run.failed, run.finished, automation.disabled, trigger.failing. Up to 50 per channel. +With --json, listing prints {"subscriptions": [...]}; a change prints the +whole channel as the platform answers it. + ` + notifCredentialHelp + ` Docs: ` + notificationsDocs, @@ -1155,6 +1158,8 @@ func doNotifSubs(ctx context.Context, env *runsEnv, ref string, change bool) err } resolved[auto] = id } + // The platform answers ids in lower case. + id = strings.ToLower(id) s.AutomationID = &id return s, nil } diff --git a/cmd/notifications_test.go b/cmd/notifications_test.go index 274c527..aab06e5 100644 --- a/cmd/notifications_test.go +++ b/cmd/notifications_test.go @@ -2,12 +2,13 @@ package cmd import ( "encoding/json" - "io" "net/http" "os" "path/filepath" "strings" "testing" + + "github.com/nxssie/nan-cli/internal/api" ) // Notification channels (helmcode/nan-devops @@ -70,7 +71,7 @@ func (f *fakeWrite) serveNotifications(t *testing.T, w http.ResponseWriter, r *h AutomationID *string `json:"automation_id"` } _ = json.Unmarshal(body, &in) - events := []string{"approval.pending", "run.failed", "automation.disabled", "trigger.failing"} + events := api.DefaultNotificationEvents if in.Events != nil { events = *in.Events } @@ -500,7 +501,7 @@ func TestNotificationsDeliveries(t *testing.T) { checkGolden(t, "notifications/deliveries.golden", r.stdout) reqs := fake.requests() last := reqs[len(reqs)-1] - if last.Path != "/v1/notification-channels/"+chanHTTPSID+"/deliveries" { + if last.Path != "/v1/notification-channels/"+chanHTTPSID+"/deliveries" || last.Query != "limit=5" { t.Errorf("%v", last) } r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "deliveries", "ci receiver", "--json") @@ -519,11 +520,18 @@ func TestNotificationsDeliveriesSendsTheLimit(t *testing.T) { mustExit(t, r, 0) for _, req := range fake.requests() { if strings.HasSuffix(req.Path, "/deliveries") { - got = req.Path + got = req.Query } } - if got == "" { - t.Fatal("no deliveries request") + if got != "limit=7" { + t.Fatalf("query %q", got) + } + // Without --limit: the 20 the help documents. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "deliveries", "team alerts") + mustExit(t, r, 0) + if p := fake.sent("GET", "/v1/notification-channels/"+chanSlackID+"/deliveries"); len(p) != 1 || p[0].Query != "limit=20" { + t.Errorf("%v", p) } } @@ -559,6 +567,8 @@ func TestNotificationsSubscriptions(t *testing.T) { `{"subscriptions":[{"event":"run.failed","automation_id":null},{"event":"approval.pending","automation_id":"` + testAutoID + `"}]}`}, {[]string{"ci receiver", "--remove", "run.finished=PR review api"}, chanHTTPSID, `{"subscriptions":[]}`}, {[]string{"ci receiver", "--clear"}, chanHTTPSID, `{"subscriptions":[]}`}, + // An id typed in capitals is the same automation. + {[]string{"ci receiver", "--remove", "run.finished=" + strings.ToUpper(testAutoID)}, chanHTTPSID, `{"subscriptions":[]}`}, } { fake := newFakeWrite() r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"notifications", "subscriptions"}, c.args...)...) @@ -708,5 +718,4 @@ func TestNotificationsUnknownVerbs(t *testing.T) { r := runWrite(t, newFakeWrite(), writeSetup{session: cliSession}, "notifications", verb) mustExit(t, r, exitUsage, want) } - _ = io.Discard } diff --git a/cmd/writeapi_test.go b/cmd/writeapi_test.go index 4dbfcae..93bf811 100644 --- a/cmd/writeapi_test.go +++ b/cmd/writeapi_test.go @@ -33,6 +33,7 @@ const testValue = "ghp_TESTVALUEdoNotLeak0123456789abcdef" type writeReq struct { Method, Path, Cookie, Authz, IdemKey, Body string + Query string } type fakeWrite struct { @@ -97,7 +98,7 @@ func (f *fakeWrite) handler(t *testing.T) http.Handler { body, _ := io.ReadAll(r.Body) f.mu.Lock() defer f.mu.Unlock() - f.reqs = append(f.reqs, writeReq{r.Method, r.URL.Path, r.Header.Get("Cookie"), r.Header.Get("Authorization"), r.Header.Get("Idempotency-Key"), string(body)}) + f.reqs = append(f.reqs, writeReq{r.Method, r.URL.Path, r.Header.Get("Cookie"), r.Header.Get("Authorization"), r.Header.Get("Idempotency-Key"), string(body), r.URL.RawQuery}) w.Header().Set("Content-Type", "application/json") if f.dropFirstCreate { // A fresh connection per request: net/http only retries a diff --git a/internal/api/notifications.go b/internal/api/notifications.go index 273aec7..bca8bce 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -153,12 +153,6 @@ func (c *RunsClient) ListNotificationChannels(ctx context.Context) (*Notificatio return list, c.getJSON(ctx, notificationChannelsPath, list) } -// GetNotificationChannel is GET /v1/notification-channels/{id}. -func (c *RunsClient) GetNotificationChannel(ctx context.Context, id string) (*NotificationChannel, error) { - ch := &NotificationChannel{} - return ch, c.getJSON(ctx, channelPath(id), ch) -} - // NotificationDeliveries is GET …/{id}/deliveries?limit=N (newest first). func (c *RunsClient) NotificationDeliveries(ctx context.Context, id string, limit int) (*NotificationDeliveryList, error) { list := &NotificationDeliveryList{} diff --git a/internal/api/notifications_test.go b/internal/api/notifications_test.go index 1267e15..7232b3e 100644 --- a/internal/api/notifications_test.go +++ b/internal/api/notifications_test.go @@ -132,7 +132,6 @@ func TestNotificationChannelRoutes(t *testing.T) { on := false steps := []func() error{ func() error { _, err := c.ListNotificationChannels(ctx); return err }, - func() error { _, err := c.GetNotificationChannel(ctx, id); return err }, func() error { _, err := c.NotificationDeliveries(ctx, id, 7); return err }, func() error { _, err := c.CreateNotificationChannel(ctx, CreateNotificationChannelRequest{Name: "a", Kind: "slack", URL: "u"}) @@ -158,7 +157,6 @@ func TestNotificationChannelRoutes(t *testing.T) { p := "/v1/notification-channels" want := []string{ "GET " + p + " ", - "GET " + p + "/" + id + " ", "GET " + p + "/" + id + "/deliveries?limit=7 ", "POST " + p + ` {"name":"a","kind":"slack","url":"u"}`, "PATCH " + p + "/" + id + ` {"enabled":false}`, From 8ae0f6e5b4f8688ea5bf7c030fe07c875e982daa Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 19:57:08 +0200 Subject: [PATCH 6/6] fix(notifications): QA round 2 nits (channel ids matched in any case, default events only in tests, generic invalid_request pinned) Co-Authored-By: Claude Opus 5.5 --- cmd/notifications.go | 2 +- cmd/notifications_test.go | 12 ++++++++---- internal/api/notifications.go | 4 ---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/notifications.go b/cmd/notifications.go index 0e449b3..dad547e 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -694,7 +694,7 @@ func listChannels(ctx context.Context, env *runsEnv) (*api.NotificationChannelLi func findChannel(list *api.NotificationChannelList, ref string) *api.NotificationChannel { for i := range list.Data { - if list.Data[i].ID == ref { + if strings.EqualFold(list.Data[i].ID, ref) { return &list.Data[i] } } diff --git a/cmd/notifications_test.go b/cmd/notifications_test.go index aab06e5..c0136f2 100644 --- a/cmd/notifications_test.go +++ b/cmd/notifications_test.go @@ -7,8 +7,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/nxssie/nan-cli/internal/api" ) // Notification channels (helmcode/nan-devops @@ -25,6 +23,10 @@ const ( testHookHTTPS = "https://hooks.example.com/nan/HOOKSECRETdoNotLeak42" ) +// defaultEvents are what the platform gives a channel created without +// events (spec §4.2: run.finished is opt-in). +var defaultEvents = []string{"approval.pending", "run.failed", "automation.disabled", "trigger.failing"} + func phase3File(t testing.TB, name string) []byte { b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "phase3", name)) if err != nil { @@ -71,7 +73,7 @@ func (f *fakeWrite) serveNotifications(t *testing.T, w http.ResponseWriter, r *h AutomationID *string `json:"automation_id"` } _ = json.Unmarshal(body, &in) - events := api.DefaultNotificationEvents + events := defaultEvents if in.Events != nil { events = *in.Events } @@ -247,7 +249,7 @@ func TestNotificationsShow(t *testing.T) { mustExit(t, r, 0) checkGolden(t, "notifications/show_failing.golden", r.stdout) - r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", chanSlackID) + r = runWrite(t, fake, writeSetup{session: cliSession}, "notifications", "show", strings.ToUpper(chanSlackID)) mustExit(t, r, 0) checkGolden(t, "notifications/show_slack.golden", r.stdout) @@ -626,6 +628,8 @@ func TestNotificationsErrors(t *testing.T) { {503, `{"error":{"message":"notifications are temporarily unavailable; try again","type":"server_error","code":"notifications_unavailable"}}`, exitUnavailable, []string{"temporarily unavailable"}}, {403, `{"error":{"message":"this token lacks the notifications:write scope","type":"permission_error","code":"token_scope_denied"}}`, exitAuth, []string{"notifications:write", "Settings > Tokens"}}, {403, `{"error":{"message":"x","type":"permission_error","code":"credential_not_allowed"}}`, exitAuth, []string{"nan auth login"}}, + {400, v1Err(400, "invalid_request", "name", "the name must be 1 to 64 characters without control characters"), exitUsage, + []string{"name: the name must be 1 to 64 characters"}}, } { fake := newFakeWrite() fake.failOn("POST /v1/notification-channels", c.status, c.body) diff --git a/internal/api/notifications.go b/internal/api/notifications.go index bca8bce..ce46f00 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -31,10 +31,6 @@ const ( // order the platform documents them. var NotificationEvents = []string{"approval.pending", "run.failed", "run.finished", "automation.disabled", "trigger.failing"} -// DefaultNotificationEvents are what a new channel gets when no events are -// given (run.finished is opt-in). -var DefaultNotificationEvents = []string{"approval.pending", "run.failed", "automation.disabled", "trigger.failing"} - // NotificationSubscription is one event of a channel, for every automation // of the member (AutomationID nil) or for one. type NotificationSubscription struct {