diff --git a/cmd/update_user.go b/cmd/update_user.go new file mode 100644 index 0000000..2655984 --- /dev/null +++ b/cmd/update_user.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "errors" + "strings" + + "code.cloudfoundry.org/uaa-cli/cli" + "code.cloudfoundry.org/uaa-cli/config" + "code.cloudfoundry.org/uaa-cli/utils" + "github.com/cloudfoundry-community/go-uaa" + "github.com/spf13/cobra" +) + +var delAttrs []string + +func UpdateUserCmd(api *uaa.API, printer cli.Printer, username, familyName, givenName, origin string, emails []string, phones []string, delAttrs []string) error { + // First, get the existing user + user, err := api.GetUserByUsername(username, origin, "") + if err != nil { + return err + } + + // Create a copy of the user for updating + toUpdate := *user + + // Update fields if provided + if familyName != "" || givenName != "" { + if toUpdate.Name == nil { + toUpdate.Name = &uaa.UserName{} + } + if familyName != "" { + toUpdate.Name.FamilyName = familyName + } + if givenName != "" { + toUpdate.Name.GivenName = givenName + } + } + + if len(emails) > 0 { + toUpdate.Emails = buildEmails(emails) + } + + if len(phones) > 0 { + toUpdate.PhoneNumbers = buildPhones(phones) + } + + // Handle attribute deletion + if len(delAttrs) > 0 { + for _, attr := range delAttrs { + switch strings.ToLower(attr) { + case "phonenumbers", "phone", "phones": + toUpdate.PhoneNumbers = nil + case "emails", "email": + // Don't allow clearing all emails as it may break the user + log.Infof("Warning: Cannot delete all emails as it may make the user unusable") + case "name", "familyname", "givenname": + if strings.ToLower(attr) == "name" || strings.ToLower(attr) == "familyname" { + if toUpdate.Name != nil { + toUpdate.Name.FamilyName = "" + } + } + if strings.ToLower(attr) == "name" || strings.ToLower(attr) == "givenname" { + if toUpdate.Name != nil { + toUpdate.Name.GivenName = "" + } + } + } + } + } + + // Update the user + updatedUser, err := api.UpdateUser(toUpdate) + if err != nil { + return err + } + + log.Infof("Account for user %v successfully updated.", utils.Emphasize(updatedUser.Username)) + return printer.Print(updatedUser) +} + +func UpdateUserValidation(cfg config.Config, args []string) error { + if err := cli.EnsureContextInConfig(cfg); err != nil { + return err + } + if len(args) == 0 { + return errors.New("The positional argument USERNAME must be specified.") + } + return nil +} + +var updateUserCmd = &cobra.Command{ + Use: "update-user USERNAME", + Short: "Update a user account", + PreRun: func(cmd *cobra.Command, args []string) { + cli.NotifyValidationErrors(UpdateUserValidation(GetSavedConfig(), args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + + if zoneSubdomain == "" { + zoneSubdomain = cfg.ZoneSubdomain + } + api := GetAPIFromSavedTokenInContext() + err := UpdateUserCmd(api, cli.NewJsonPrinter(log), args[0], familyName, givenName, origin, emails, phoneNumbers, delAttrs) + cli.NotifyErrorsWithRetry(err, log, GetSavedConfig()) + }, +} + +func init() { + RootCmd.AddCommand(updateUserCmd) + updateUserCmd.Annotations = make(map[string]string) + updateUserCmd.Annotations[USER_CRUD_CATEGORY] = "true" + + updateUserCmd.Flags().StringVarP(&familyName, "family_name", "", "", "family name") + updateUserCmd.Flags().StringVarP(&givenName, "given_name", "", "", "given name") + updateUserCmd.Flags().StringVarP(&origin, "origin", "o", "", "user origin") + updateUserCmd.Flags().StringSliceVarP(&emails, "emails", "", []string{}, "email addresses (multiple may be specified)") + updateUserCmd.Flags().StringSliceVarP(&phoneNumbers, "phones", "", []string{}, "phone numbers (multiple may be specified)") + updateUserCmd.Flags().StringSliceVarP(&delAttrs, "del_attrs", "", []string{}, "attributes to remove (phoneNumbers, name, etc.)") + updateUserCmd.Flags().StringVarP(&zoneSubdomain, "zone", "z", "", "the identity zone subdomain in which to update the user") +} \ No newline at end of file diff --git a/cmd/update_user_test.go b/cmd/update_user_test.go new file mode 100644 index 0000000..163090c --- /dev/null +++ b/cmd/update_user_test.go @@ -0,0 +1,284 @@ +package cmd_test + +import ( + "code.cloudfoundry.org/uaa-cli/cli" + "code.cloudfoundry.org/uaa-cli/config" + "code.cloudfoundry.org/uaa-cli/fixtures" + "github.com/cloudfoundry-community/go-uaa" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" + . "github.com/onsi/gomega/ghttp" + "net/http" +) + +var _ = Describe("UpdateUser", func() { + BeforeEach(func() { + cfg := config.NewConfigWithServerURL(server.URL()) + cfg.AddContext(config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ")) + config.WriteConfig(cfg) + }) + + Describe("Validations", func() { + It("requires a target to have been set", func() { + config.WriteConfig(config.NewConfig()) + + session := runCommand("update-user") + + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say(cli.MISSING_TARGET)) + }) + + It("requires a token in context", func() { + config.WriteConfig(config.NewConfigWithServerURL(server.URL())) + + session := runCommand("update-user") + + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say(cli.MISSING_CONTEXT)) + }) + + It("requires a username", func() { + session := runCommand("update-user") + + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say("The positional argument USERNAME must be specified.")) + }) + }) + + Describe("UpdateUserCmd", func() { + Describe("Success cases", func() { + It("updates user with given_name only", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + VerifyHeaderKV("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"), + VerifyHeaderKV("Accept", "application/json"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + VerifyHeaderKV("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"), + VerifyHeaderKV("Accept", "application/json"), + VerifyHeaderKV("Content-Type", "application/json"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", "--given_name", "Bob") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + Expect(session.Out).To(Say("Account for user marcus@stoicism.com successfully updated")) + }) + + It("updates user with multiple attributes", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--given_name", "Bob", + "--family_name", "Smith") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + Expect(session.Out).To(Say("Account for user marcus@stoicism.com successfully updated")) + }) + + It("updates user with origin specified", func() { + // First GET to retrieve user with origin filter + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + VerifyFormKV("filter", `userName eq "marcus@stoicism.com" and origin eq "ldap"`), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--origin", "ldap", + "--given_name", "Bob") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + }) + + It("updates user with emails", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--emails", "new@email.com") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + }) + + It("updates user with phones", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--phones", "555-1234") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + }) + + It("updates user with del_attrs removing phone numbers", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--del_attrs", "phoneNumbers") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + }) + + It("works with zone parameter", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + VerifyHeaderKV("X-Identity-Zone-Id", "twilight-zone"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + VerifyHeaderKV("X-Identity-Zone-Id", "twilight-zone"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--given_name", "Bob", + "--zone", "twilight-zone") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + }) + + It("prints the updated user json", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", "--given_name", "Bob") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + Expect(session.Out).To(Say("marcus@stoicism.com")) + Expect(session.Out).To(Say("fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70")) + }) + }) + + Describe("Error cases", func() { + It("displays an error when user is not found", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusNotFound, ""), + VerifyRequest("GET", "/Users"), + )) + + session := runCommand("update-user", "nobody", "--given_name", "Bob") + + Expect(server.ReceivedRequests()).To(HaveLen(1)) + Expect(session).To(Exit(1)) + }) + + It("displays an error if there is a problem during update", func() { + // First GET succeeds + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // But PUT fails + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusBadRequest, ""), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", "--given_name", "Bob") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(1)) + }) + }) + + Describe("Verbose mode", func() { + It("shows PUT endpoint when verbose flag is used", func() { + // First GET to retrieve user + server.RouteToHandler("GET", "/Users", CombineHandlers( + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{ID: "fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", Username: "marcus@stoicism.com"})), + VerifyRequest("GET", "/Users"), + )) + + // Then PUT to update user + server.RouteToHandler("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70", CombineHandlers( + RespondWith(http.StatusOK, fixtures.MarcusUserResponse), + VerifyRequest("PUT", "/Users/fb5f32e1-5cb3-49e6-93df-6df9c8c8bd70"), + )) + + session := runCommand("update-user", "marcus@stoicism.com", + "--given_name", "Bob", + "--verbose") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Expect(session).To(Exit(0)) + // Note: Verbose output would show the actual PUT request details + // but this depends on the go-uaa library's verbose logging implementation + }) + }) + }) +}) \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md index 48a8cdc..779ea15 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -44,6 +44,7 @@ Each command name below links to a page with a full description, including all a |---------|-------------| | [`create-user`](commands/create-user.md) | Create a user | | [`get-user`](commands/get-user.md) | Look up a user by username | +| [`update-user`](commands/update-user.md) | Update a user account | | [`list-users`](commands/list-users.md) | Search and list users with SCIM filters | | [`delete-user`](commands/delete-user.md) | Delete a user by username | | [`activate-user`](commands/activate-user.md) | Activate a user by username | diff --git a/docs/commands/update-user.md b/docs/commands/update-user.md new file mode 100644 index 0000000..d264e8b --- /dev/null +++ b/docs/commands/update-user.md @@ -0,0 +1,77 @@ +# update-user + +[← Command Reference](../commands.md) + +Update an existing user account in the UAA. + +## Usage + +``` +uaa update-user USERNAME [flags] +``` + +## Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--given_name` | | | Given (first) name | +| `--family_name` | | | Family (last) name | +| `--emails` | | | Email addresses (flag may be specified multiple times) | +| `--phones` | | | Phone numbers (flag may be specified multiple times) | +| `--origin` | `-o` | | Identity provider origin to search for user (e.g. `uaa`, `ldap`) | +| `--del_attrs` | | | Attributes to remove (e.g. `phoneNumbers`, `name`) | +| `--zone` | `-z` | | Identity zone subdomain in which to update the user | + +## Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--verbose` | `-v` | Print additional info on HTTP requests | + +## Examples + +```bash +# Update a user's name +uaa update-user bob --given_name Robert --family_name Smith + +# Update a user's email addresses +uaa update-user alice --emails alice@newdomain.com --emails alice.jones@work.com + +# Update a user from a specific origin +uaa update-user carol --origin ldap --given_name Caroline + +# Remove phone numbers from a user +uaa update-user bob --del_attrs phoneNumbers + +# Update multiple attributes at once +uaa update-user alice \ + --given_name Alice \ + --family_name Johnson \ + --emails alice.johnson@example.com \ + --phones 555-1234 + +# Update user in a specific zone with verbose output +uaa update-user bob \ + --given_name Robert \ + --zone my-zone \ + --verbose +``` + +## Notes + +- The command first retrieves the existing user, then merges the specified updates +- At least one update flag must be specified +- When using `--del_attrs`, be careful not to remove required attributes +- The `--emails` attribute cannot be deleted as it may make the user unusable +- Use `--verbose` to see the HTTP PUT request details + +## See Also + +- [create-user](create-user.md) +- [get-user](get-user.md) +- [list-users](list-users.md) +- [delete-user](delete-user.md) + +--- + +[← Command Reference](../commands.md) \ No newline at end of file