diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index ebbd780b636..2b053a0a854 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -516,6 +516,7 @@ func InitLocal(srv *app.Server) *API { api.InitSamlLocal() api.InitCustomProfileAttributesLocal() api.InitAccessControlPolicyLocal() + api.InitStatusLocal() srv.LocalRouter.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/status_local.go b/server/channels/api4/status_local.go new file mode 100644 index 00000000000..8e1e8ece044 --- /dev/null +++ b/server/channels/api4/status_local.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import "net/http" + +func (api *API) InitStatusLocal() { + api.BaseRoutes.User.Handle("/status", api.APILocal(getUserStatus)).Methods(http.MethodGet) + api.BaseRoutes.User.Handle("/status", api.APILocal(updateUserStatus)).Methods(http.MethodPut) +} diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go index 3e130447ae1..3c9b3ac5f8c 100644 --- a/server/cmd/mmctl/client/client.go +++ b/server/cmd/mmctl/client/client.go @@ -91,6 +91,8 @@ type Client interface { ConvertBotToUser(ctx context.Context, userID string, userPatch *model.UserPatch, setSystemAdmin bool) (*model.User, *model.Response, error) PromoteGuestToUser(ctx context.Context, userID string) (*model.Response, error) DemoteUserToGuest(ctx context.Context, guestID string) (*model.Response, error) + GetUserStatus(ctx context.Context, userID, etag string) (*model.Status, *model.Response, error) + UpdateUserStatus(ctx context.Context, userID string, userStatus *model.Status) (*model.Status, *model.Response, error) CreateCommand(ctx context.Context, cmd *model.Command) (*model.Command, *model.Response, error) ListCommands(ctx context.Context, teamID string, customOnly bool) ([]*model.Command, *model.Response, error) GetCommandById(ctx context.Context, cmdID string) (*model.Command, *model.Response, error) diff --git a/server/cmd/mmctl/commands/user.go b/server/cmd/mmctl/commands/user.go index 2062983c85c..421dff8e96f 100644 --- a/server/cmd/mmctl/commands/user.go +++ b/server/cmd/mmctl/commands/user.go @@ -12,6 +12,7 @@ import ( "os" "sort" "testing" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/require" @@ -22,6 +23,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var UserCmd = &cobra.Command{ @@ -211,6 +213,38 @@ var DemoteUserToGuestCmd = &cobra.Command{ Args: cobra.MinimumNArgs(1), } +var UserStatusCmd = &cobra.Command{ + Use: "status", + Short: "Get a user's status", + Long: "Get a user's presence status: online, away, dnd or offline.", + Example: ` # You can get the status of the currently authenticated user + $ mmctl user status + + # You can get the status of a specific user + $ mmctl user status --user user@example.com + + # In local mode there is no authenticated user, so the --user flag is required + $ mmctl --local user status --user user@example.com`, + Args: cobra.NoArgs, + RunE: withClient(userStatusGetCmdF), +} + +var UserStatusSetCmd = &cobra.Command{ + Use: "set [status]", + Short: "Set a user's status", + Long: "Set a user's presence status. Allowed values are online, away, dnd and offline.", + Example: ` # You can set the status of the currently authenticated user + $ mmctl user status set away + + # You can set the status of a specific user + $ mmctl user status set --user user@example.com dnd + + # You can set a "dnd" status that expires at a given time (ISO 8601) + $ mmctl user status set --user user@example.com --dnd-end-time 2100-01-02T15:04:05-07:00 dnd`, + Args: cobra.ExactArgs(1), + RunE: withClient(userStatusSetCmdF), +} + var UserConvertCmd = &cobra.Command{ Use: "convert (--bot [emails] [usernames] [userIds] | --user --password PASSWORD [--email EMAIL])", Short: "Convert users to bots, or a bot to a user", @@ -368,6 +402,10 @@ func init() { UserConvertCmd.Flags().String("locale", "", "The locale (ex: en, fr) for converted new user account. Required when the \"bot\" flag is set") UserConvertCmd.Flags().Bool("system-admin", false, "If supplied, the converted user will be a system administrator. Defaults to false. Required when the \"bot\" flag is set") + UserStatusCmd.Flags().String("user", "", "Optional. The user (specified by email, username or ID) whose status to get. Defaults to the currently authenticated user. Required in local mode.") + UserStatusSetCmd.Flags().String("user", "", "Optional. The user (specified by email, username or ID) whose status to set. Defaults to the currently authenticated user. Required in local mode.") + UserStatusSetCmd.Flags().String("dnd-end-time", "", "Optional. The time at which a \"dnd\" status expires, formatted as ISO 8601 (e.g. 2006-01-02T15:04:05-07:00). Only valid with the \"dnd\" status.") + ChangePasswordUserCmd.Flags().StringP("current", "c", "", "The current password of the user. Use only if changing your own password") ChangePasswordUserCmd.Flags().StringP("password", "p", "", "The new password for the user") ChangePasswordUserCmd.Flags().Bool("hashed", false, "The supplied password is already hashed") @@ -423,8 +461,12 @@ Global Flags: MigrateAuthCmd, PromoteGuestToUserCmd, DemoteUserToGuestCmd, + UserStatusCmd, PreferenceCmd, ) + UserStatusCmd.AddCommand( + UserStatusSetCmd, + ) PreferenceCmd.AddCommand( PreferenceListCmd, PreferenceGetCmd, @@ -1073,6 +1115,91 @@ func demoteUserToGuestCmdF(c client.Client, _ *cobra.Command, userArgs []string) return errs.ErrorOrNil() } +// resolveStatusTargetUser resolves the user whose status a command operates on. +// When the --user flag is omitted, it falls back to the currently authenticated +// user, which is unavailable in local mode. +func resolveStatusTargetUser(c client.Client, cmd *cobra.Command) (*model.User, error) { + userArg, _ := cmd.Flags().GetString("user") + if userArg != "" { + return getUserFromArg(c, userArg) + } + + if viper.GetBool("local") { + return nil, errors.New("the --user flag is required in local mode") + } + + me, _, err := c.GetMe(context.TODO(), "") + if err != nil { + return nil, fmt.Errorf("could not retrieve the current user: %w", err) + } + return me, nil +} + +func userStatusGetCmdF(c client.Client, cmd *cobra.Command, _ []string) error { + printer.SetSingle(true) + + user, err := resolveStatusTargetUser(c, cmd) + if err != nil { + return err + } + + status, _, err := c.GetUserStatus(context.TODO(), user.Id, "") + if err != nil { + return fmt.Errorf("could not get status for user %s: %w", user.Id, err) + } + + printer.PrintT("@"+user.Username+" has status: {{.Status}}", status) + return nil +} + +func userStatusSetCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + newStatus := args[0] + switch newStatus { + case model.StatusOnline, model.StatusAway, model.StatusDnd, model.StatusOffline: + default: + return fmt.Errorf("invalid status %q, must be one of: %s, %s, %s, %s", newStatus, model.StatusOnline, model.StatusAway, model.StatusDnd, model.StatusOffline) + } + + dndEndTimeArg, _ := cmd.Flags().GetString("dnd-end-time") + if dndEndTimeArg != "" && newStatus != model.StatusDnd { + return fmt.Errorf("the --dnd-end-time flag can only be used with the %q status", model.StatusDnd) + } + + var dndEndTime int64 + if dndEndTimeArg != "" { + endTime, err := time.Parse(time.RFC3339, dndEndTimeArg) + if err != nil { + return fmt.Errorf("invalid dnd-end-time %q, expected RFC3339 format (e.g. 2006-01-02T15:04:05-07:00 or 2006-01-02T15:04:05Z)", dndEndTimeArg) + } + if !endTime.After(time.Now()) { + return errors.New("dnd-end-time must be in the future") + } + // DNDEndTime is expressed in seconds rather than milliseconds. + dndEndTime = endTime.Unix() + } + + user, err := resolveStatusTargetUser(c, cmd) + if err != nil { + return err + } + + status := &model.Status{ + UserId: user.Id, + Status: newStatus, + DNDEndTime: dndEndTime, + } + + updatedStatus, _, err := c.UpdateUserStatus(context.TODO(), user.Id, status) + if err != nil { + return fmt.Errorf("could not set status for user %s: %w", user.Id, err) + } + + printer.PrintT("Set status of @"+user.Username+" to status: {{.Status}}", updatedStatus) + return nil +} + func userEditCompletionF(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/server/cmd/mmctl/commands/user_e2e_test.go b/server/cmd/mmctl/commands/user_e2e_test.go index 0b149afe6f6..f053b9a27b3 100644 --- a/server/cmd/mmctl/commands/user_e2e_test.go +++ b/server/cmd/mmctl/commands/user_e2e_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "time" "github.com/hashicorp/go-multierror" "github.com/mattermost/mattermost/server/public/model" @@ -1812,3 +1813,173 @@ func (s *MmctlE2ETestSuite) TestUserEditAuthdataCmd() { s.Require().Equal(newAuthdata, *updatedUser.AuthData) }) } + +func (s *MmctlE2ETestSuite) TestUserStatusGetCmd() { + s.SetupTestHelper().InitBasic(s.T()) + + s.RunForSystemAdminAndLocal("Get the status of a specific user", func(c client.Client) { + printer.Clean() + + s.th.App.SaveAndBroadcastStatus(&model.Status{UserId: s.th.BasicUser.Id, Status: model.StatusOnline, Manual: true}) + + cmd := newUserStatusCmd() + s.Require().NoError(cmd.Flags().Set("user", s.th.BasicUser.Email)) + + err := userStatusGetCmdF(c, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + status, ok := printer.GetLines()[0].(*model.Status) + s.Require().True(ok) + s.Require().Equal(s.th.BasicUser.Id, status.UserId) + s.Require().Equal(model.StatusOnline, status.Status) + }) + + s.Run("Get the status of the authenticated user when --user is omitted", func() { + printer.Clean() + + s.th.App.SaveAndBroadcastStatus(&model.Status{UserId: s.th.SystemAdminUser.Id, Status: model.StatusOnline, Manual: true}) + + err := userStatusGetCmdF(s.th.SystemAdminClient, newUserStatusCmd(), []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + status, ok := printer.GetLines()[0].(*model.Status) + s.Require().True(ok) + s.Require().Equal(s.th.SystemAdminUser.Id, status.UserId) + s.Require().Equal(model.StatusOnline, status.Status) + }) + + s.Run("A regular user can read another user's status", func() { + printer.Clean() + + s.th.App.SaveAndBroadcastStatus(&model.Status{UserId: s.th.BasicUser2.Id, Status: model.StatusOnline, Manual: true}) + + cmd := newUserStatusCmd() + s.Require().NoError(cmd.Flags().Set("user", s.th.BasicUser2.Email)) + + err := userStatusGetCmdF(s.th.Client, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + status, ok := printer.GetLines()[0].(*model.Status) + s.Require().True(ok) + s.Require().Equal(s.th.BasicUser2.Id, status.UserId) + s.Require().Equal(model.StatusOnline, status.Status) + }) + + s.RunForAllClients("Get the status of a nonexistent user", func(c client.Client) { + printer.Clean() + + cmd := newUserStatusCmd() + s.Require().NoError(cmd.Flags().Set("user", "nonexistent@example.com")) + + err := userStatusGetCmdF(c, cmd, []string{}) + s.Require().EqualError(err, "user nonexistent@example.com not found") + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestUserStatusSetCmd() { + s.SetupTestHelper().InitBasic(s.T()) + + s.RunForSystemAdminAndLocal("Set the status of a specific user", func(c client.Client) { + printer.Clean() + + cmd := newUserStatusSetCmd() + s.Require().NoError(cmd.Flags().Set("user", s.th.BasicUser.Email)) + + err := userStatusSetCmdF(c, cmd, []string{model.StatusDnd}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + status, ok := printer.GetLines()[0].(*model.Status) + s.Require().True(ok) + s.Require().Equal(model.StatusDnd, status.Status) + + stored, appErr := s.th.App.GetStatus(s.th.BasicUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.StatusDnd, stored.Status) + }) + + s.RunForSystemAdminAndLocal("Set a dnd status with an end time", func(c client.Client) { + printer.Clean() + + endTimeArg := time.Now().Add(2 * time.Hour).Format(ISO8601Layout) + endTime, err := time.Parse(ISO8601Layout, endTimeArg) + s.Require().NoError(err) + + cmd := newUserStatusSetCmd() + s.Require().NoError(cmd.Flags().Set("user", s.th.BasicUser.Email)) + s.Require().NoError(cmd.Flags().Set("dnd-end-time", endTimeArg)) + + err = userStatusSetCmdF(c, cmd, []string{model.StatusDnd}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + stored, appErr := s.th.App.GetStatus(s.th.BasicUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.StatusDnd, stored.Status) + // The server truncates the DND end time to the minute to align with the expiry job. + s.Require().Equal(endTime.Truncate(model.DNDExpiryInterval).Unix(), stored.DNDEndTime) + }) + + s.Run("Set the status of the authenticated user when --user is omitted", func() { + printer.Clean() + + err := userStatusSetCmdF(s.th.SystemAdminClient, newUserStatusSetCmd(), []string{model.StatusAway}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + stored, appErr := s.th.App.GetStatus(s.th.SystemAdminUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.StatusAway, stored.Status) + }) + + s.Run("A regular user can set their own status when --user is omitted", func() { + printer.Clean() + + // Seed a distinct starting status so the read-back proves the write happened + // rather than passing on leftover state from earlier subtests. + s.th.App.SaveAndBroadcastStatus(&model.Status{UserId: s.th.BasicUser.Id, Status: model.StatusDnd, Manual: true}) + + err := userStatusSetCmdF(s.th.Client, newUserStatusSetCmd(), []string{model.StatusAway}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + + status, ok := printer.GetLines()[0].(*model.Status) + s.Require().True(ok) + s.Require().Equal(model.StatusAway, status.Status) + + stored, appErr := s.th.App.GetStatus(s.th.BasicUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.StatusAway, stored.Status) + }) + + s.Run("A regular user cannot set another user's status", func() { + printer.Clean() + + cmd := newUserStatusSetCmd() + s.Require().NoError(cmd.Flags().Set("user", s.th.SystemAdminUser.Email)) + + err := userStatusSetCmdF(s.th.Client, cmd, []string{model.StatusOnline}) + s.Require().Error(err) + s.CheckErrorID(err, "api.context.permissions.app_error") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Reject an invalid status value", func() { + printer.Clean() + + err := userStatusSetCmdF(s.th.SystemAdminClient, newUserStatusSetCmd(), []string{"busy"}) + s.Require().EqualError(err, "invalid status \"busy\", must be one of: online, away, dnd, offline") + s.Require().Len(printer.GetLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/user_test.go b/server/cmd/mmctl/commands/user_test.go index 504ad778c80..61d388bcbb9 100644 --- a/server/cmd/mmctl/commands/user_test.go +++ b/server/cmd/mmctl/commands/user_test.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" @@ -18,6 +19,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/viper" ) func (s *MmctlUnitTestSuite) TestUserActivateCmd() { @@ -3140,3 +3142,362 @@ func (s *MmctlUnitTestSuite) TestUserEditAuthdataCmd() { s.Require().EqualError(err, "failed to update user authdata: API error") }) } + +func newUserStatusCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("user", "", "") + return cmd +} + +func newUserStatusSetCmd() *cobra.Command { + cmd := newUserStatusCmd() + cmd.Flags().String("dnd-end-time", "", "") + return cmd +} + +func (s *MmctlUnitTestSuite) TestUserStatusCmdWiring() { + s.Run("status command is registered under user with a set subcommand", func() { + s.Require().True(UserCmd.HasSubCommands()) + s.Require().Contains(UserCmd.Commands(), UserStatusCmd) + s.Require().Contains(UserStatusCmd.Commands(), UserStatusSetCmd) + }) + + s.Run("the expected flags are registered", func() { + s.Require().NotNil(UserStatusCmd.Flags().Lookup("user")) + s.Require().NotNil(UserStatusSetCmd.Flags().Lookup("user")) + s.Require().NotNil(UserStatusSetCmd.Flags().Lookup("dnd-end-time")) + }) + + s.Run("argument contracts are enforced", func() { + s.Require().NoError(UserStatusCmd.Args(UserStatusCmd, []string{})) + s.Require().Error(UserStatusCmd.Args(UserStatusCmd, []string{"online"})) + s.Require().NoError(UserStatusSetCmd.Args(UserStatusSetCmd, []string{"online"})) + s.Require().Error(UserStatusSetCmd.Args(UserStatusSetCmd, []string{})) + }) +} + +func (s *MmctlUnitTestSuite) TestUserStatusGetCmd() { + s.Run("Get status of the current user when --user is omitted", func() { + printer.Clean() + + mockUser := model.User{Id: "me-id", Username: "me"} + mockStatus := &model.Status{UserId: "me-id", Status: model.StatusOnline} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserStatus(context.TODO(), "me-id", ""). + Return(mockStatus, &model.Response{}, nil). + Times(1) + + err := userStatusGetCmdF(s.client, newUserStatusCmd(), []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockStatus, printer.GetLines()[0]) + }) + + s.Run("Get status of a specific user via --user", func() { + printer.Clean() + + cmd := newUserStatusCmd() + err := cmd.Flags().Set("user", "target@example.com") + s.Require().NoError(err) + + mockUser := model.User{Id: "target-id", Username: "target", Email: "target@example.com"} + mockStatus := &model.Status{UserId: "target-id", Status: model.StatusDnd} + + s.client. + EXPECT(). + GetUserByEmail(context.TODO(), "target@example.com", ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserStatus(context.TODO(), "target-id", ""). + Return(mockStatus, &model.Response{}, nil). + Times(1) + + err = userStatusGetCmdF(s.client, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockStatus, printer.GetLines()[0]) + }) + + s.Run("Require --user in local mode", func() { + printer.Clean() + prevLocal := viper.GetBool("local") + viper.Set("local", true) + defer viper.Set("local", prevLocal) + + err := userStatusGetCmdF(s.client, newUserStatusCmd(), []string{}) + s.Require().EqualError(err, "the --user flag is required in local mode") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when status retrieval fails", func() { + printer.Clean() + + mockUser := model.User{Id: "me-id", Username: "me"} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserStatus(context.TODO(), "me-id", ""). + Return(nil, &model.Response{}, errors.New("status error")). + Times(1) + + err := userStatusGetCmdF(s.client, newUserStatusCmd(), []string{}) + s.Require().EqualError(err, "could not get status for user me-id: status error") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when user cannot be found", func() { + printer.Clean() + + cmd := newUserStatusCmd() + err := cmd.Flags().Set("user", "ghost") + s.Require().NoError(err) + + s.client. + EXPECT(). + GetUserByUsername(context.TODO(), "ghost", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(context.TODO(), "ghost", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err = userStatusGetCmdF(s.client, cmd, []string{}) + s.Require().EqualError(err, "user ghost not found") + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestUserStatusSetCmd() { + s.Run("Set status of the current user when --user is omitted", func() { + printer.Clean() + + mockUser := model.User{Id: "me-id", Username: "me"} + wantStatus := &model.Status{UserId: "me-id", Status: model.StatusAway} + returnedStatus := &model.Status{UserId: "me-id", Status: model.StatusAway, Manual: true} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserStatus(context.TODO(), "me-id", wantStatus). + Return(returnedStatus, &model.Response{}, nil). + Times(1) + + err := userStatusSetCmdF(s.client, newUserStatusSetCmd(), []string{model.StatusAway}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(returnedStatus, printer.GetLines()[0]) + }) + + s.Run("Set status of a specific user via --user", func() { + printer.Clean() + + cmd := newUserStatusSetCmd() + err := cmd.Flags().Set("user", "target@example.com") + s.Require().NoError(err) + + mockUser := model.User{Id: "target-id", Username: "target", Email: "target@example.com"} + wantStatus := &model.Status{UserId: "target-id", Status: model.StatusOffline} + + s.client. + EXPECT(). + GetUserByEmail(context.TODO(), "target@example.com", ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserStatus(context.TODO(), "target-id", wantStatus). + Return(wantStatus, &model.Response{}, nil). + Times(1) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusOffline}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(wantStatus, printer.GetLines()[0]) + }) + + s.Run("Set dnd status with an end time", func() { + printer.Clean() + + endTimeArg := time.Now().Add(24 * time.Hour).Format(ISO8601Layout) + endTime, err := time.Parse(ISO8601Layout, endTimeArg) + s.Require().NoError(err) + + cmd := newUserStatusSetCmd() + err = cmd.Flags().Set("dnd-end-time", endTimeArg) + s.Require().NoError(err) + + mockUser := model.User{Id: "me-id", Username: "me"} + wantStatus := &model.Status{UserId: "me-id", Status: model.StatusDnd, DNDEndTime: endTime.Unix()} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserStatus(context.TODO(), "me-id", wantStatus). + Return(wantStatus, &model.Response{}, nil). + Times(1) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusDnd}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(wantStatus, printer.GetLines()[0]) + }) + + s.Run("Reject an invalid status value", func() { + printer.Clean() + + err := userStatusSetCmdF(s.client, newUserStatusSetCmd(), []string{"busy"}) + s.Require().EqualError(err, "invalid status \"busy\", must be one of: online, away, dnd, offline") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Reject --dnd-end-time with a non-dnd status", func() { + printer.Clean() + + cmd := newUserStatusSetCmd() + err := cmd.Flags().Set("dnd-end-time", time.Now().Add(time.Hour).Format(ISO8601Layout)) + s.Require().NoError(err) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusAway}) + s.Require().EqualError(err, "the --dnd-end-time flag can only be used with the \"dnd\" status") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Set dnd status with a UTC end time", func() { + printer.Clean() + + endTime := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + endTimeArg := endTime.Format(time.RFC3339) + + cmd := newUserStatusSetCmd() + err := cmd.Flags().Set("dnd-end-time", endTimeArg) + s.Require().NoError(err) + + mockUser := model.User{Id: "me-id", Username: "me"} + wantStatus := &model.Status{UserId: "me-id", Status: model.StatusDnd, DNDEndTime: endTime.Unix()} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserStatus(context.TODO(), "me-id", wantStatus). + Return(wantStatus, &model.Response{}, nil). + Times(1) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusDnd}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(wantStatus, printer.GetLines()[0]) + }) + + s.Run("Reject a dnd-end-time in the past", func() { + printer.Clean() + + cmd := newUserStatusSetCmd() + err := cmd.Flags().Set("dnd-end-time", time.Now().Add(-time.Hour).Format(ISO8601Layout)) + s.Require().NoError(err) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusDnd}) + s.Require().EqualError(err, "dnd-end-time must be in the future") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Reject a malformed dnd-end-time", func() { + printer.Clean() + + cmd := newUserStatusSetCmd() + err := cmd.Flags().Set("dnd-end-time", "not-a-time") + s.Require().NoError(err) + + err = userStatusSetCmdF(s.client, cmd, []string{model.StatusDnd}) + s.Require().EqualError(err, "invalid dnd-end-time \"not-a-time\", expected RFC3339 format (e.g. 2006-01-02T15:04:05-07:00 or 2006-01-02T15:04:05Z)") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when the current user cannot be resolved", func() { + printer.Clean() + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(nil, &model.Response{}, errors.New("me error")). + Times(1) + + err := userStatusSetCmdF(s.client, newUserStatusSetCmd(), []string{model.StatusOnline}) + s.Require().EqualError(err, "could not retrieve the current user: me error") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Require --user in local mode", func() { + printer.Clean() + prevLocal := viper.GetBool("local") + viper.Set("local", true) + defer viper.Set("local", prevLocal) + + err := userStatusSetCmdF(s.client, newUserStatusSetCmd(), []string{model.StatusOnline}) + s.Require().EqualError(err, "the --user flag is required in local mode") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when status update fails", func() { + printer.Clean() + + mockUser := model.User{Id: "me-id", Username: "me"} + wantStatus := &model.Status{UserId: "me-id", Status: model.StatusOnline} + + s.client. + EXPECT(). + GetMe(context.TODO(), ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserStatus(context.TODO(), "me-id", wantStatus). + Return(nil, &model.Response{}, errors.New("update error")). + Times(1) + + err := userStatusSetCmdF(s.client, newUserStatusSetCmd(), []string{model.StatusOnline}) + s.Require().EqualError(err, "could not set status for user me-id: update error") + s.Require().Len(printer.GetLines(), 0) + }) +} diff --git a/server/cmd/mmctl/docs/mmctl_user.rst b/server/cmd/mmctl/docs/mmctl_user.rst index 5e01be8965c..84033ee79e0 100644 --- a/server/cmd/mmctl/docs/mmctl_user.rst +++ b/server/cmd/mmctl/docs/mmctl_user.rst @@ -54,5 +54,6 @@ SEE ALSO * `mmctl user reset-password `_ - Send users an email to reset their password * `mmctl user resetmfa `_ - Turn off MFA * `mmctl user search `_ - Search for users +* `mmctl user status `_ - Get a user's status * `mmctl user verify `_ - Mark user's email as verified diff --git a/server/cmd/mmctl/docs/mmctl_user_status.rst b/server/cmd/mmctl/docs/mmctl_user_status.rst new file mode 100644 index 00000000000..be0e4752fde --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_status.rst @@ -0,0 +1,60 @@ +.. _mmctl_user_status: + +mmctl user status +----------------- + +Get a user's status + +Synopsis +~~~~~~~~ + + +Get a user's presence status: online, away, dnd or offline. + +:: + + mmctl user status [flags] + +Examples +~~~~~~~~ + +:: + + # You can get the status of the currently authenticated user + $ mmctl user status + + # You can get the status of a specific user + $ mmctl user status --user user@example.com + + # In local mode there is no authenticated user, so the --user flag is required + $ mmctl --local user status --user user@example.com + +Options +~~~~~~~ + +:: + + -h, --help help for status + --user string Optional. The user (specified by email, username or ID) whose status to get. Defaults to the currently authenticated user. Required in local mode. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users +* `mmctl user status set `_ - Set a user's status + diff --git a/server/cmd/mmctl/docs/mmctl_user_status_set.rst b/server/cmd/mmctl/docs/mmctl_user_status_set.rst new file mode 100644 index 00000000000..3725498042f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_status_set.rst @@ -0,0 +1,60 @@ +.. _mmctl_user_status_set: + +mmctl user status set +--------------------- + +Set a user's status + +Synopsis +~~~~~~~~ + + +Set a user's presence status. Allowed values are online, away, dnd and offline. + +:: + + mmctl user status set [status] [flags] + +Examples +~~~~~~~~ + +:: + + # You can set the status of the currently authenticated user + $ mmctl user status set away + + # You can set the status of a specific user + $ mmctl user status set --user user@example.com dnd + + # You can set a "dnd" status that expires at a given time (ISO 8601) + $ mmctl user status set --user user@example.com --dnd-end-time 2100-01-02T15:04:05-07:00 dnd + +Options +~~~~~~~ + +:: + + --dnd-end-time string Optional. The time at which a "dnd" status expires, formatted as ISO 8601 (e.g. 2006-01-02T15:04:05-07:00). Only valid with the "dnd" status. + -h, --help help for set + --user string Optional. The user (specified by email, username or ID) whose status to set. Defaults to the currently authenticated user. Required in local mode. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user status `_ - Get a user's status + diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go index 44e0d8a2f37..ece5e6bd353 100644 --- a/server/cmd/mmctl/mocks/client_mock.go +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -1564,6 +1564,22 @@ func (mr *MockClientMockRecorder) GetUserByUsername(arg0, arg1, arg2 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockClient)(nil).GetUserByUsername), arg0, arg1, arg2) } +// GetUserStatus mocks base method. +func (m *MockClient) GetUserStatus(arg0 context.Context, arg1, arg2 string) (*model.Status, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserStatus", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Status) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUserStatus indicates an expected call of GetUserStatus. +func (mr *MockClientMockRecorder) GetUserStatus(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStatus", reflect.TypeOf((*MockClient)(nil).GetUserStatus), arg0, arg1, arg2) +} + // GetUsers mocks base method. func (m *MockClient) GetUsers(arg0 context.Context, arg1, arg2 int, arg3 string) ([]*model.User, *model.Response, error) { m.ctrl.T.Helper() @@ -2575,6 +2591,22 @@ func (mr *MockClientMockRecorder) UpdateUserRoles(arg0, arg1, arg2 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserRoles", reflect.TypeOf((*MockClient)(nil).UpdateUserRoles), arg0, arg1, arg2) } +// UpdateUserStatus mocks base method. +func (m *MockClient) UpdateUserStatus(arg0 context.Context, arg1 string, arg2 *model.Status) (*model.Status, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserStatus", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Status) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateUserStatus indicates an expected call of UpdateUserStatus. +func (mr *MockClientMockRecorder) UpdateUserStatus(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockClient)(nil).UpdateUserStatus), arg0, arg1, arg2) +} + // UploadData mocks base method. func (m *MockClient) UploadData(arg0 context.Context, arg1 string, arg2 io.Reader) (*model.FileInfo, *model.Response, error) { m.ctrl.T.Helper()