diff --git a/cmd/create/cluster/cmd.go b/cmd/create/cluster/cmd.go index b993cd91b3..a354684dbf 100644 --- a/cmd/create/cluster/cmd.go +++ b/cmd/create/cluster/cmd.go @@ -50,6 +50,7 @@ import ( "github.com/openshift/rosa/pkg/aws/tags" "github.com/openshift/rosa/pkg/clusterautoscaler" "github.com/openshift/rosa/pkg/clusterregistryconfig" + "github.com/openshift/rosa/pkg/config" "github.com/openshift/rosa/pkg/fedramp" "github.com/openshift/rosa/pkg/helper" mpHelpers "github.com/openshift/rosa/pkg/helper/machinepools" @@ -57,6 +58,7 @@ import ( urlHelper "github.com/openshift/rosa/pkg/helper/url" "github.com/openshift/rosa/pkg/helper/versions" "github.com/openshift/rosa/pkg/ingress" + "github.com/openshift/rosa/pkg/input" "github.com/openshift/rosa/pkg/interactive" "github.com/openshift/rosa/pkg/interactive/confirm" "github.com/openshift/rosa/pkg/interactive/consts" @@ -105,6 +107,7 @@ const ( privateLinkFlagName = "private-link" privateFlagName = "private" enableDeleteProtectionFlagName = "enable-delete-protection" + notificationContactsFlagName = "notification-contacts" ) var args struct { @@ -139,6 +142,7 @@ var args struct { flavour string disableWorkloadMonitoring bool enableDeleteProtection bool + notificationContacts []string ec2MetadataHttpTokens string //Encryption @@ -730,6 +734,14 @@ func initFlags(cmd *cobra.Command) { false, "Enable cluster delete protection against accidental deletion after the cluster is created.", ) + flags.StringSliceVar( + &args.notificationContacts, + notificationContactsFlagName, + nil, + "Comma-separated list of OCM account usernames or email addresses to receive "+ + "cluster notification emails. All contacts must belong to the same Red Hat "+ + "organization as the cluster.", + ) flags.BoolVarP( &args.watch, @@ -3142,6 +3154,34 @@ func run(cmd *cobra.Command, _ []string) { args.enableDeleteProtection = enableDeleteProtection } + notificationContacts := args.notificationContacts + showNotificationContactsPrompt := false + if interactive.Enabled() && cmd.Flags().Changed("interactive") { + showNotificationContactsPrompt = true + } else if interactive.Enabled() { + cfg, cfgErr := config.Load() + if cfgErr == nil && cfg.ClientID != "" { + showNotificationContactsPrompt = true + } + } + if showNotificationContactsPrompt { + ncInput, err := interactive.GetString(interactive.Input{ + Question: "Notification contact usernames or emails (comma-separated, leave empty to skip)", + Help: cmd.Flags().Lookup(notificationContactsFlagName).Usage, + Default: strings.Join(notificationContacts, ","), + }) + if err != nil { + r.Reporter.Errorf("Expected a valid notification-contacts value: %v", err) + os.Exit(1) + } + if ncInput != "" { + notificationContacts = helper.HandleEmptyStringOnSlice(strings.Split(ncInput, ",")) + } else { + notificationContacts = nil + } + args.notificationContacts = notificationContacts + } + // Cluster-wide proxy configuration if (subnetsProvided || (useExistingVPC && !enableProxy)) && interactive.Enabled() { enableProxy, err = interactive.GetBool(interactive.Input{ @@ -3723,6 +3763,30 @@ func run(cmd *cobra.Command, _ []string) { } } + if len(notificationContacts) == 1 && + (notificationContacts[0] == input.DoubleQuotesToRemove || notificationContacts[0] == "") { + notificationContacts = []string{} + } + if len(notificationContacts) > 0 { + subID := cluster.Subscription().ID() + if subID == "" { + r.Reporter.Errorf( + "Cluster '%s' was created but notification contacts could not be set: "+ + "subscription ID is not available", + cluster.ID(), + ) + os.Exit(1) + } + if err := r.OCMClient.UpdateSubscriptionNotificationContacts(cmd.Context(), subID, notificationContacts); err != nil { + r.Reporter.Errorf( + "Cluster '%s' was created but notification contacts could not be set: %v", + cluster.ID(), + err, + ) + os.Exit(1) + } + } + if !output.HasFlag() || r.Reporter.IsTerminal() { r.Reporter.Infof("Cluster '%s' has been created.", clusterName) r.Reporter.Infof( @@ -4344,6 +4408,9 @@ func buildCommand(spec ocm.Spec, operatorRolesPrefix string, if args.enableDeleteProtection { command += " --enable-delete-protection" } + if len(args.notificationContacts) > 0 { + command += fmt.Sprintf(" --notification-contacts %s", strings.Join(args.notificationContacts, ",")) + } if userSelectedAvailabilityZones { command += fmt.Sprintf(" --availability-zones %s", strings.Join(spec.AvailabilityZones, ",")) } diff --git a/cmd/describe/cluster/cmd.go b/cmd/describe/cluster/cmd.go index af398a471e..7600104977 100644 --- a/cmd/describe/cluster/cmd.go +++ b/cmd/describe/cluster/cmd.go @@ -122,6 +122,15 @@ func run(cmd *cobra.Command, argv []string) { } } + var notificationContactUsernames []string + if subscriptionExists { + contacts, err := r.OCMClient.GetSubscriptionNotificationContacts(cmd.Context(), cluster.Subscription().ID()) + if err != nil { + r.Reporter.Debugf("Failed to get notification contacts: %s", err) + } + notificationContactUsernames = contacts + } + if !isHypershift { scheduledUpgrade, upgradeState, err = r.OCMClient.GetScheduledUpgrade(cluster.ID()) if err != nil { @@ -135,6 +144,11 @@ func run(cmd *cobra.Command, argv []string) { r.Reporter.Errorf("%s", err) os.Exit(1) } + if len(notificationContactUsernames) > 0 { + f["notification_contacts"] = notificationContactUsernames + } else { + f["notification_contacts"] = []string{} + } err = output.Print(f) if err != nil { r.Reporter.Errorf("%s", err) @@ -155,6 +169,11 @@ func run(cmd *cobra.Command, argv []string) { r.Reporter.Errorf("%s", err) os.Exit(1) } + if len(notificationContactUsernames) > 0 { + f["notification_contacts"] = notificationContactUsernames + } else { + f["notification_contacts"] = []string{} + } err = output.Print(f) if err != nil { r.Reporter.Errorf("%s", err) @@ -447,6 +466,18 @@ func run(cmd *cobra.Command, argv []string) { deleteProtection = EnabledOutput } + notificationContactsDisplay := "" + if len(notificationContactUsernames) > 0 { + const maxDisplayContacts = 25 + if len(notificationContactUsernames) > maxDisplayContacts { + notificationContactsDisplay = output.PrintStringSlice(notificationContactUsernames[:maxDisplayContacts]) + + fmt.Sprintf(", ... (%d more, use --output json to see all)", + len(notificationContactUsernames)-maxDisplayContacts) + } else { + notificationContactsDisplay = output.PrintStringSlice(notificationContactUsernames) + } + } + str = fmt.Sprintf("%s"+ "State: %s %s\n"+ "Private: %s\n"+ @@ -458,6 +489,13 @@ func run(cmd *cobra.Command, argv []string) { deleteProtection, cluster.CreationTimestamp().Format("Jan _2 2006 15:04:05 MST")) + if notificationContactsDisplay != "" { + str = fmt.Sprintf("%s"+ + "Notification Contacts: %s\n", + str, + notificationContactsDisplay) + } + if !isHypershift { str = fmt.Sprintf("%s"+ "User Workload Monitoring: %s\n", diff --git a/cmd/edit/cluster/cmd.go b/cmd/edit/cluster/cmd.go index 71fbe30566..584cc1493f 100644 --- a/cmd/edit/cluster/cmd.go +++ b/cmd/edit/cluster/cmd.go @@ -29,6 +29,7 @@ import ( "github.com/openshift/rosa/pkg/arguments" "github.com/openshift/rosa/pkg/aws" "github.com/openshift/rosa/pkg/clusterregistryconfig" + "github.com/openshift/rosa/pkg/config" "github.com/openshift/rosa/pkg/fedramp" "github.com/openshift/rosa/pkg/helper" "github.com/openshift/rosa/pkg/helper/autonode" @@ -42,12 +43,14 @@ import ( ) const enableDeleteProtectionFlagName = "enable-delete-protection" +const notificationContactsFlagName = "notification-contacts" var args struct { // Basic options expirationTime string expirationDuration time.Duration enableDeleteProtection bool + notificationContacts []string // Networking options private bool @@ -135,6 +138,14 @@ func initFlags(cmd *cobra.Command) { "Enable or disable cluster delete protection against accidental deletion. "+ "Use '--enable-delete-protection=false' to disable.", ) + flags.StringSliceVar( + &args.notificationContacts, + notificationContactsFlagName, + nil, + "Comma-separated list of OCM account usernames or email addresses to receive "+ + "cluster notification emails. All contacts must belong to the same Red Hat "+ + "organization as the cluster. Pass an empty value to remove all notification contacts.", + ) // Cluster expiration is not supported in production flags.MarkHidden("expiration-time") flags.MarkHidden("expiration") @@ -292,7 +303,7 @@ func runWithRuntime(r *rosa.Runtime, cmd *cobra.Command) error { "registry-config-insecure-registries", "allowed-registries-for-import", "registry-config-platform-allowlist", "registry-config-additional-trusted-ca", "billing-account", "registry-config-allowed-registries-for-import", "enable-delete-protection", "spot-termination-queue-url", - "channel-group", "network-type", "channel"} { + "channel-group", "network-type", "channel", notificationContactsFlagName} { if cmd.Flags().Changed(flag) { changedFlags = true break @@ -853,6 +864,82 @@ func runWithRuntime(r *rosa.Runtime, cmd *cobra.Command) error { } } + // Notification Contacts + var notificationContacts []string + updateNotificationContacts := cmd.Flags().Changed(notificationContactsFlagName) + if updateNotificationContacts { + notificationContacts = args.notificationContacts + } + showNotificationContactsPrompt := false + if !updateNotificationContacts && interactive.Enabled() { + if cmd.Flags().Changed("interactive") { + showNotificationContactsPrompt = true + } else { + cfg, cfgErr := config.Load() + if cfgErr == nil && cfg.ClientID != "" { + showNotificationContactsPrompt = true + } + } + } + if showNotificationContactsPrompt { + updateValue, err := interactive.GetBool(interactive.Input{ + Question: "Update notification contacts", + Default: false, + }) + if err != nil { + r.Reporter.Errorf("Expected a valid value: %v", err) + os.Exit(1) + } + updateNotificationContacts = updateValue + } + if updateNotificationContacts && interactive.Enabled() { + promptDefault := strings.Join(notificationContacts, ",") + if !cmd.Flags().Changed(notificationContactsFlagName) { + currentContacts, err := r.OCMClient.GetSubscriptionNotificationContacts( + cmd.Context(), cluster.Subscription().ID()) + if err != nil { + r.Reporter.Errorf("Could not fetch current notification contacts: %v", err) + os.Exit(1) + } + const maxPromptContacts = 5 + if len(currentContacts) > maxPromptContacts { + r.Reporter.Infof("Cluster has %d notification contacts configured. "+ + "Enter the full desired list below.", len(currentContacts)) + promptDefault = "" + } else { + promptDefault = strings.Join(currentContacts, ",") + } + } + ncInput, err := interactive.GetString(interactive.Input{ + Question: "Notification contact usernames or emails (comma-separated)", + Help: cmd.Flags().Lookup(notificationContactsFlagName).Usage, + Default: promptDefault, + }) + if err != nil { + r.Reporter.Errorf("Expected a valid value for notification contacts: %v", err) + os.Exit(1) + } + notificationContacts = helper.HandleEmptyStringOnSlice(strings.Split(ncInput, ",")) + } + if updateNotificationContacts { + subID := cluster.Subscription().ID() + if subID == "" { + r.Reporter.Errorf("Cluster subscription ID is not available. " + + "Notification contacts could not be updated.") + os.Exit(1) + } + contacts := notificationContacts + if len(contacts) == 1 && + (contacts[0] == input.DoubleQuotesToRemove || contacts[0] == "") { + contacts = []string{} + } + if err := r.OCMClient.UpdateSubscriptionNotificationContacts(cmd.Context(), subID, contacts); err != nil { + r.Reporter.Errorf("Failed to update notification contacts: %v", err) + os.Exit(1) + } + r.Reporter.Infof("Updated notification contacts for cluster '%s'", clusterKey) + } + // SDN -> OVN Migration var migrateNetworkType bool // Only prompt user with migrating the cluster's network type when it is not OVN-Kubernetes diff --git a/cmd/rosa/structure_test/command_args/rosa/create/cluster/command_args.yml b/cmd/rosa/structure_test/command_args/rosa/create/cluster/command_args.yml index f11bdf1b92..1b2e4c21d8 100644 --- a/cmd/rosa/structure_test/command_args/rosa/create/cluster/command_args.yml +++ b/cmd/rosa/structure_test/command_args/rosa/create/cluster/command_args.yml @@ -82,6 +82,7 @@ - name: disable-scp-checks - name: disable-workload-monitoring - name: enable-delete-protection +- name: notification-contacts - name: watch - name: dry-run - name: fake-cluster diff --git a/cmd/rosa/structure_test/command_args/rosa/edit/cluster/command_args.yml b/cmd/rosa/structure_test/command_args/rosa/edit/cluster/command_args.yml index 5f1f6591df..b48ae47c40 100644 --- a/cmd/rosa/structure_test/command_args/rosa/edit/cluster/command_args.yml +++ b/cmd/rosa/structure_test/command_args/rosa/edit/cluster/command_args.yml @@ -3,6 +3,7 @@ - name: expiration-time - name: expiration - name: enable-delete-protection +- name: notification-contacts - name: private - name: disable-workload-monitoring - name: http-proxy diff --git a/pkg/ocm/clusters.go b/pkg/ocm/clusters.go index 47d12bab23..a96ddc383a 100644 --- a/pkg/ocm/clusters.go +++ b/pkg/ocm/clusters.go @@ -17,11 +17,15 @@ limitations under the License. package ocm import ( + "context" + "encoding/json" "fmt" "net" "os" "reflect" + "sort" "strconv" + "strings" "time" idputils "github.com/openshift-online/ocm-common/pkg/idp/utils" @@ -819,6 +823,147 @@ func (c *Client) UpdateClusterDeleteProtection(clusterId string, deleteProtectio return nil } +// UpdateSubscriptionNotificationContacts performs a diff against the current notification contacts +// on a subscription and issues POST/DELETE calls to reconcile to the desired set. +func (c *Client) UpdateSubscriptionNotificationContacts( + ctx context.Context, subscriptionID string, contacts []string, +) error { + basePath := fmt.Sprintf("/api/accounts_mgmt/v1/subscriptions/%s/notification_contacts", subscriptionID) + + currentContacts, err := c.fetchNotificationContacts(ctx, basePath) + if err != nil { + return err + } + + current := make(map[string]string, len(currentContacts)) + for _, c := range currentContacts { + current[c.Username] = c.ID + } + + seen := make(map[string]bool, len(contacts)) + deduplicated := make([]string, 0, len(contacts)) + for _, u := range contacts { + u = strings.TrimSpace(u) + if u != "" && !seen[u] { + seen[u] = true + deduplicated = append(deduplicated, u) + } + } + + var added []string + var addErrors []string + for _, username := range deduplicated { + if _, exists := current[username]; !exists { + body, err := json.Marshal(map[string]string{"account_identifier": username}) + if err != nil { + addErrors = append(addErrors, fmt.Sprintf("'%s': %v", username, err)) + continue + } + resp, err := c.ocm.Post().Path(basePath).Bytes(body).SendContext(ctx) + if err != nil { + addErrors = append(addErrors, fmt.Sprintf("'%s': %v", username, err)) + continue + } + if resp.Status() >= 400 { + addErrors = append(addErrors, fmt.Sprintf("'%s': HTTP %d: %s", + username, resp.Status(), resp.String())) + continue + } + added = append(added, username) + } + } + + for username, accountID := range current { + if !seen[username] { + deletePath := fmt.Sprintf("%s/%s", basePath, accountID) + resp, err := c.ocm.Delete().Path(deletePath).SendContext(ctx) + if err != nil { + return fmt.Errorf("can't remove notification contact '%s': %v", username, err) + } + if resp.Status() >= 400 { + return fmt.Errorf("can't remove notification contact '%s': HTTP %d", username, resp.Status()) + } + } + } + + if len(addErrors) > 0 { + summary := fmt.Sprintf("failed to add %d of %d notification contact(s): %s", + len(addErrors), len(addErrors)+len(added), strings.Join(addErrors, "; ")) + if len(added) > 0 { + summary += fmt.Sprintf(". Successfully added: %s", strings.Join(added, ", ")) + } + return fmt.Errorf("%s", summary) + } + + return nil +} + +// GetSubscriptionNotificationContacts returns the usernames of all notification contacts on the +// given subscription ordered by username, or nil if none exist. +func (c *Client) GetSubscriptionNotificationContacts( + ctx context.Context, subscriptionID string, +) ([]string, error) { + basePath := fmt.Sprintf("/api/accounts_mgmt/v1/subscriptions/%s/notification_contacts", subscriptionID) + + contacts, err := c.fetchNotificationContacts(ctx, basePath) + if err != nil { + return nil, err + } + if len(contacts) == 0 { + return nil, nil + } + usernames := make([]string, 0, len(contacts)) + for _, c := range contacts { + usernames = append(usernames, c.Username) + } + sort.Strings(usernames) + return usernames, nil +} + +type notificationContactResponse struct { + ID string `json:"id"` + Username string `json:"username"` +} + +type notificationContactListResponse struct { + Items []notificationContactResponse `json:"items"` + Size int `json:"size"` +} + +// fetchNotificationContacts returns all notification contacts at the given API path, paginating +// through all results. +func (c *Client) fetchNotificationContacts(ctx context.Context, path string) ([]notificationContactResponse, error) { + const pageSize = 100 + var result []notificationContactResponse + page := 1 + for { + resp, err := c.ocm.Get().Path(path). + Parameter("page", page). + Parameter("size", pageSize). + SendContext(ctx) + if err != nil { + return nil, fmt.Errorf("can't read notification contacts: %v", err) + } + if resp.Status() >= 400 { + return nil, fmt.Errorf("can't read notification contacts: HTTP %d: %s", resp.Status(), resp.String()) + } + var listResp notificationContactListResponse + if err := json.Unmarshal(resp.Bytes(), &listResp); err != nil { + return nil, fmt.Errorf("can't parse notification contacts response: %v", err) + } + for _, item := range listResp.Items { + if item.Username != "" && item.ID != "" { + result = append(result, item) + } + } + if listResp.Size < pageSize { + break + } + page++ + } + return result, nil +} + // EnsureNoPendingClusters ensures that no clusters are pending in the account. For non-STS clusters, // the osdCcsAdmin user credentials are used to create the cluster, and it is required that these credentials // are rotated between cluster creation. If a user is creating a non-STS cluster, we need to therefore make sure diff --git a/pkg/ocm/notification_contacts_test.go b/pkg/ocm/notification_contacts_test.go new file mode 100644 index 0000000000..2218cf0920 --- /dev/null +++ b/pkg/ocm/notification_contacts_test.go @@ -0,0 +1,417 @@ +package ocm + +import ( + "context" + "io" + "net/http" + "time" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" + sdk "github.com/openshift-online/ocm-sdk-go" + "github.com/openshift-online/ocm-sdk-go/logging" + . "github.com/openshift-online/ocm-sdk-go/testing" +) + +const subscriptionId = "sub123" +const notificationContactsPath = "/api/accounts_mgmt/v1/subscriptions/sub123/notification_contacts" +const notificationContactsFirstPage = "page=1&size=100" + +var _ = Describe("NotificationContacts", func() { + var ssoServer, apiServer *ghttp.Server + var ocmClient *Client + + BeforeEach(func() { + ssoServer = MakeTCPServer() + apiServer = MakeTCPServer() + apiServer.SetAllowUnhandledRequests(true) + apiServer.SetUnhandledRequestStatusCode(http.StatusInternalServerError) + + accessToken := MakeTokenString("Bearer", 15*time.Minute) + ssoServer.AppendHandlers( + RespondWithAccessToken(accessToken), + ) + + logger, err := logging.NewGoLoggerBuilder(). + Debug(true). + Build() + Expect(err).NotTo(HaveOccurred()) + + connection, err := sdk.NewConnectionBuilder(). + Logger(logger). + Tokens(accessToken). + URL(apiServer.URL()). + Build() + Expect(err).NotTo(HaveOccurred()) + ocmClient = &Client{ocm: connection} + }) + + AfterEach(func() { + ssoServer.Close() + apiServer.Close() + Expect(ocmClient.Close()).To(Succeed()) + }) + + Describe("UpdateSubscriptionNotificationContacts", func() { + It("adds contacts when none exist", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user1"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc1", "username": "user1", + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user2"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc2", "username": "user2", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts(context.Background(), subscriptionId, []string{"user1", "user2"}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("removes contacts not in the desired list", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc1", "username": "user1"}, + map[string]string{"id": "acc2", "username": "user2"}, + }, + "size": 2, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodDelete, notificationContactsPath+"/acc2"), + ghttp.RespondWith(http.StatusNoContent, ""), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts(context.Background(), subscriptionId, []string{"user1"}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("clears all contacts with an empty list", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc1", "username": "user1"}, + }, + "size": 1, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodDelete, notificationContactsPath+"/acc1"), + ghttp.RespondWith(http.StatusNoContent, ""), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts(context.Background(), subscriptionId, []string{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an error when the list request fails", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusForbidden, map[string]interface{}{ + "kind": "Error", "reason": "forbidden", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts(context.Background(), subscriptionId, []string{"user1"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("can't read notification contacts")) + }) + + It("returns an error when adding a contact fails", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + ghttp.RespondWithJSONEncoded(http.StatusBadRequest, map[string]interface{}{ + "kind": "Error", "reason": "invalid username", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts(context.Background(), subscriptionId, []string{"bad_user"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add 1 of 1")) + }) + + It("continues adding remaining contacts after one fails and reports summary", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + ghttp.RespondWithJSONEncoded(http.StatusBadRequest, map[string]interface{}{ + "kind": "Error", "reason": "invalid username", + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc2", "username": "good_user", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts( + context.Background(), subscriptionId, []string{"bad_user", "good_user"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add 1 of 2")) + Expect(err.Error()).To(ContainSubstring("Successfully added: good_user")) + Expect(apiServer.ReceivedRequests()).To(HaveLen(3)) + }) + + It("deduplicates contacts and sends only one POST per unique username", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc1", "username": "user1", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts( + context.Background(), subscriptionId, []string{"user1", "user1", "user1"}) + Expect(err).NotTo(HaveOccurred()) + Expect(apiServer.ReceivedRequests()).To(HaveLen(2)) + }) + + It("trims whitespace and skips empty entries", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user1"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc1", "username": "user1", + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user2"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc2", "username": "user2", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts( + context.Background(), subscriptionId, []string{" user1 ", "", " user2", " ", "user1"}) + Expect(err).NotTo(HaveOccurred()) + Expect(apiServer.ReceivedRequests()).To(HaveLen(3)) + }) + + It("reports HTTP 409 when username and email resolve to the same account", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user1"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusCreated, map[string]interface{}{ + "id": "acc1", "username": "user1", + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(ContainSubstring(`"account_identifier":"user1@example.com"`)) + }, + ghttp.RespondWithJSONEncoded(http.StatusConflict, map[string]interface{}{ + "kind": "Error", "reason": "notification contact already exists", + }), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts( + context.Background(), subscriptionId, []string{"user1", "user1@example.com"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add 1 of 2")) + Expect(err.Error()).To(ContainSubstring("user1@example.com")) + Expect(err.Error()).To(ContainSubstring("Successfully added: user1")) + }) + + It("still deletes removed contacts even when an add fails", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc1", "username": "old_user"}, + }, + "size": 1, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodPost, notificationContactsPath), + ghttp.RespondWithJSONEncoded(http.StatusBadRequest, map[string]interface{}{ + "kind": "Error", "reason": "invalid username", + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodDelete, notificationContactsPath+"/acc1"), + ghttp.RespondWith(http.StatusNoContent, ""), + ), + ) + + err := ocmClient.UpdateSubscriptionNotificationContacts( + context.Background(), subscriptionId, []string{"new_user"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add 1 of 1")) + Expect(apiServer.ReceivedRequests()).To(HaveLen(3)) + }) + }) + + Describe("GetSubscriptionNotificationContacts", func() { + It("returns sorted usernames", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc2", "username": "zeta_user"}, + map[string]string{"id": "acc1", "username": "alpha_user"}, + }, + "size": 2, + }), + ), + ) + + contacts, err := ocmClient.GetSubscriptionNotificationContacts(context.Background(), subscriptionId) + Expect(err).NotTo(HaveOccurred()) + Expect(contacts).To(Equal([]string{"alpha_user", "zeta_user"})) + }) + + It("returns nil when no contacts exist", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{}, + "size": 0, + }), + ), + ) + + contacts, err := ocmClient.GetSubscriptionNotificationContacts(context.Background(), subscriptionId) + Expect(err).NotTo(HaveOccurred()) + Expect(contacts).To(BeNil()) + }) + + It("paginates through multiple pages of contacts", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc1", "username": "user_page1"}, + }, + "size": 100, + }), + ), + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, "page=2&size=100"), + ghttp.RespondWithJSONEncoded(http.StatusOK, map[string]interface{}{ + "items": []interface{}{ + map[string]string{"id": "acc2", "username": "user_page2"}, + }, + "size": 1, + }), + ), + ) + + contacts, err := ocmClient.GetSubscriptionNotificationContacts(context.Background(), subscriptionId) + Expect(err).NotTo(HaveOccurred()) + Expect(contacts).To(Equal([]string{"user_page1", "user_page2"})) + }) + + It("returns an error when the API responds with a server error", func() { + apiServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, notificationContactsPath, notificationContactsFirstPage), + ghttp.RespondWithJSONEncoded(http.StatusInternalServerError, map[string]interface{}{ + "kind": "Error", "reason": "internal server error", + }), + ), + ) + + contacts, err := ocmClient.GetSubscriptionNotificationContacts(context.Background(), subscriptionId) + Expect(err).To(HaveOccurred()) + Expect(contacts).To(BeNil()) + }) + }) +})