snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# mmctl
`mmctl` is a remote CLI tool that lets you manage your Mattermost instance from the terminal.
The project's documentation can be accessed within the [the Mattermost documentation](https://docs.mattermost.com/manage/mmctl-command-line-tool.html).

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package client
import (
"context"
"encoding/json"
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
type Client interface {
CreateChannel(ctx context.Context, channel *model.Channel) (*model.Channel, *model.Response, error)
CreateDirectChannel(ctx context.Context, userID1, userID2 string) (*model.Channel, *model.Response, error)
RemoveUserFromChannel(ctx context.Context, channelID, userID string) (*model.Response, error)
GetChannelMembers(ctx context.Context, channelID string, page, perPage int, etag string) (model.ChannelMembers, *model.Response, error)
AddChannelMember(ctx context.Context, channelID, userID string) (*model.ChannelMember, *model.Response, error)
DeleteChannel(ctx context.Context, channelID string) (*model.Response, error)
PermanentDeleteChannel(ctx context.Context, channelID string) (*model.Response, error)
MoveChannel(ctx context.Context, channelID, teamID string, force bool) (*model.Channel, *model.Response, error)
GetPublicChannelsForTeam(ctx context.Context, teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error)
GetDeletedChannelsForTeam(ctx context.Context, teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error)
GetPrivateChannelsForTeam(ctx context.Context, teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error)
GetChannelsForTeamForUser(ctx context.Context, teamID, userID string, includeDeleted bool, etag string) ([]*model.Channel, *model.Response, error)
RestoreChannel(ctx context.Context, channelID string) (*model.Channel, *model.Response, error)
PatchChannel(ctx context.Context, channelID string, patch *model.ChannelPatch) (*model.Channel, *model.Response, error)
GetChannelByName(ctx context.Context, channelName, teamID string, etag string) (*model.Channel, *model.Response, error)
GetChannelByNameIncludeDeleted(ctx context.Context, channelName, teamID string, etag string) (*model.Channel, *model.Response, error)
GetChannel(ctx context.Context, channelID string) (*model.Channel, *model.Response, error)
GetTeam(ctx context.Context, teamID, etag string) (*model.Team, *model.Response, error)
GetTeamByName(ctx context.Context, name, etag string) (*model.Team, *model.Response, error)
GetAllTeams(ctx context.Context, etag string, page int, perPage int) ([]*model.Team, *model.Response, error)
CreateTeam(ctx context.Context, team *model.Team) (*model.Team, *model.Response, error)
PatchTeam(ctx context.Context, teamID string, patch *model.TeamPatch) (*model.Team, *model.Response, error)
AddTeamMember(ctx context.Context, teamID, userID string) (*model.TeamMember, *model.Response, error)
GetTeamMembersByIds(ctx context.Context, teamID string, userIDs []string) ([]*model.TeamMember, *model.Response, error)
RemoveTeamMember(ctx context.Context, teamID, userID string) (*model.Response, error)
SoftDeleteTeam(ctx context.Context, teamID string) (*model.Response, error)
PermanentDeleteTeam(ctx context.Context, teamID string) (*model.Response, error)
RestoreTeam(ctx context.Context, teamID string) (*model.Team, *model.Response, error)
UpdateTeamPrivacy(ctx context.Context, teamID string, privacy string) (*model.Team, *model.Response, error)
SearchTeams(ctx context.Context, search *model.TeamSearch) ([]*model.Team, *model.Response, error)
GetPost(ctx context.Context, postID string, etag string) (*model.Post, *model.Response, error)
CreatePost(ctx context.Context, post *model.Post) (*model.Post, *model.Response, error)
RevealPost(ctx context.Context, postID string) (*model.Post, *model.Response, error)
GetPostsForChannel(ctx context.Context, channelID string, page, perPage int, etag string, collapsedThreads bool, includeDeleted bool) (*model.PostList, *model.Response, error)
GetPostsSince(ctx context.Context, channelID string, since int64, collapsedThreads bool) (*model.PostList, *model.Response, error)
DoAPIPost(ctx context.Context, url string, data string) (*http.Response, error)
GetLdapGroups(ctx context.Context) ([]*model.Group, *model.Response, error)
GetGroupsByChannel(ctx context.Context, channelID string, groupOpts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error)
GetGroupsByTeam(ctx context.Context, teamID string, groupOpts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error)
RestoreGroup(ctx context.Context, groupID string, etag string) (*model.Group, *model.Response, error)
GetOldClientLicense(ctx context.Context, etag string) (map[string]string, *model.Response, error)
UploadLicenseFile(ctx context.Context, data []byte) (*model.Response, error)
RemoveLicenseFile(ctx context.Context) (*model.Response, error)
GetLogs(ctx context.Context, page, perPage int) ([]string, *model.Response, error)
GetRoleByName(ctx context.Context, name string) (*model.Role, *model.Response, error)
GetAllRoles(ctx context.Context) ([]*model.Role, *model.Response, error)
PatchRole(ctx context.Context, roleID string, patch *model.RolePatch) (*model.Role, *model.Response, error)
UploadPlugin(ctx context.Context, file io.Reader) (*model.Manifest, *model.Response, error)
UploadPluginForced(ctx context.Context, file io.Reader) (*model.Manifest, *model.Response, error)
RemovePlugin(ctx context.Context, id string) (*model.Response, error)
EnablePlugin(ctx context.Context, id string) (*model.Response, error)
DisablePlugin(ctx context.Context, id string) (*model.Response, error)
GetPlugins(ctx context.Context) (*model.PluginsResponse, *model.Response, error)
GetMe(ctx context.Context, etag string) (*model.User, *model.Response, error)
GetUser(ctx context.Context, userID, etag string) (*model.User, *model.Response, error)
GetUserByUsername(ctx context.Context, userName, etag string) (*model.User, *model.Response, error)
GetUserByEmail(ctx context.Context, email, etag string) (*model.User, *model.Response, error)
GetUsersByIds(ctx context.Context, userIDs []string) ([]*model.User, *model.Response, error)
GetUsersWithCustomQueryParameters(ctx context.Context, page, perPage int, queryParameters string, etag string) ([]*model.User, *model.Response, error)
GetUsersInTeam(ctx context.Context, teamID string, page, perPage int, etag string) ([]*model.User, *model.Response, error)
GetUsersInChannel(ctx context.Context, channelID string, page, perPage int, etag string) ([]*model.User, *model.Response, error)
PermanentDeleteUser(ctx context.Context, userID string) (*model.Response, error)
PermanentDeleteAllUsers(ctx context.Context) (*model.Response, error)
CreateUser(ctx context.Context, user *model.User) (*model.User, *model.Response, error)
VerifyUserEmailWithoutToken(ctx context.Context, userID string) (*model.User, *model.Response, error)
UpdateUserRoles(ctx context.Context, userID, roles string) (*model.Response, error)
InviteUsersToTeam(ctx context.Context, teamID string, userEmails []string) (*model.Response, error)
SendPasswordResetEmail(ctx context.Context, email string) (*model.Response, error)
UpdateUser(ctx context.Context, user *model.User) (*model.User, *model.Response, error)
UpdateUserAuth(ctx context.Context, userId string, userAuth *model.UserAuth) (*model.UserAuth, *model.Response, error)
UpdateUserMfa(ctx context.Context, userID, code string, activate bool) (*model.Response, error)
UpdateUserPassword(ctx context.Context, userID, currentPassword, newPassword string) (*model.Response, error)
UpdateUserHashedPassword(ctx context.Context, userID, newHashedPassword string) (*model.Response, error)
CreateUserAccessToken(ctx context.Context, userID, description string, expiresAt int64) (*model.UserAccessToken, *model.Response, error)
RevokeUserAccessToken(ctx context.Context, tokenID string) (*model.Response, error)
RotateUserAccessToken(ctx context.Context, tokenID string, expiresAt int64) (*model.UserAccessToken, *model.Response, error)
GetUserAccessTokensForUser(ctx context.Context, userID string, page, perPage int) ([]*model.UserAccessToken, *model.Response, error)
ConvertUserToBot(ctx context.Context, userID string) (*model.Bot, *model.Response, error)
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)
UpdateCommand(ctx context.Context, cmd *model.Command) (*model.Command, *model.Response, error)
MoveCommand(ctx context.Context, teamID string, commandID string) (*model.Response, error)
DeleteCommand(ctx context.Context, commandID string) (*model.Response, error)
GetConfig(ctx context.Context) (*model.Config, *model.Response, error)
GetConfigWithOptions(ctx context.Context, options model.GetConfigOptions) (map[string]any, *model.Response, error)
GetClientConfig(ctx context.Context, etag string) (map[string]string, *model.Response, error)
UpdateConfig(context.Context, *model.Config) (*model.Config, *model.Response, error)
PatchConfig(context.Context, *model.Config) (*model.Config, *model.Response, error)
ReloadConfig(ctx context.Context) (*model.Response, error)
MigrateConfig(ctx context.Context, from, to string) (*model.Response, error)
SyncLdap(ctx context.Context) (*model.Response, error)
MigrateIdLdap(ctx context.Context, toAttribute string) (*model.Response, error)
GetUsers(ctx context.Context, page, perPage int, etag string) ([]*model.User, *model.Response, error)
UpdateUserActive(ctx context.Context, userID string, activate bool) (*model.Response, error)
UpdateTeam(ctx context.Context, team *model.Team) (*model.Team, *model.Response, error)
UpdateChannelPrivacy(ctx context.Context, channelID string, privacy model.ChannelType) (*model.Channel, *model.Response, error)
CreateBot(ctx context.Context, bot *model.Bot) (*model.Bot, *model.Response, error)
PatchBot(ctx context.Context, userID string, patch *model.BotPatch) (*model.Bot, *model.Response, error)
GetBots(ctx context.Context, page, perPage int, etag string) ([]*model.Bot, *model.Response, error)
GetBotsIncludeDeleted(ctx context.Context, page, perPage int, etag string) ([]*model.Bot, *model.Response, error)
GetBotsOrphaned(ctx context.Context, page, perPage int, etag string) ([]*model.Bot, *model.Response, error)
DisableBot(ctx context.Context, botUserID string) (*model.Bot, *model.Response, error)
EnableBot(ctx context.Context, botUserID string) (*model.Bot, *model.Response, error)
AssignBot(ctx context.Context, botUserID, newOwnerID string) (*model.Bot, *model.Response, error)
SetServerBusy(ctx context.Context, secs int) (*model.Response, error)
ClearServerBusy(ctx context.Context) (*model.Response, error)
GetServerBusy(ctx context.Context) (*model.ServerBusyState, *model.Response, error)
CheckIntegrity(ctx context.Context) ([]model.IntegrityCheckResult, *model.Response, error)
InstallPluginFromURL(context.Context, string, bool) (*model.Manifest, *model.Response, error)
InstallMarketplacePlugin(context.Context, *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.Response, error)
GetMarketplacePlugins(context.Context, *model.MarketplacePluginFilter) ([]*model.MarketplacePlugin, *model.Response, error)
MigrateAuthToLdap(ctx context.Context, fromAuthService string, matchField string, force bool) (*model.Response, error)
MigrateAuthToSaml(ctx context.Context, fromAuthService string, usersMap map[string]string, auto bool) (*model.Response, error)
GetPing(ctx context.Context) (string, *model.Response, error)
GetPingWithFullServerStatus(ctx context.Context) (map[string]any, *model.Response, error)
GetPingWithOptions(ctx context.Context, options model.SystemPingOptions) (map[string]any, *model.Response, error)
CreateUpload(ctx context.Context, us *model.UploadSession) (*model.UploadSession, *model.Response, error)
GetUpload(ctx context.Context, uploadID string) (*model.UploadSession, *model.Response, error)
GetUploadsForUser(ctx context.Context, userID string) ([]*model.UploadSession, *model.Response, error)
UploadData(ctx context.Context, uploadID string, data io.Reader) (*model.FileInfo, *model.Response, error)
ListImports(ctx context.Context) ([]string, *model.Response, error)
DeleteImport(ctx context.Context, name string) (*model.Response, error)
GetJob(ctx context.Context, id string) (*model.Job, *model.Response, error)
GetJobs(ctx context.Context, jobType string, status string, page int, perPage int) ([]*model.Job, *model.Response, error)
GetJobsByType(ctx context.Context, jobType string, page int, perPage int) ([]*model.Job, *model.Response, error)
CreateJob(ctx context.Context, job *model.Job) (*model.Job, *model.Response, error)
CancelJob(ctx context.Context, jobID string) (*model.Response, error)
UpdateJobStatus(ctx context.Context, jobId string, status string, force bool) (*model.Response, error)
CreateIncomingWebhook(ctx context.Context, hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error)
UpdateIncomingWebhook(ctx context.Context, hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error)
GetIncomingWebhooks(ctx context.Context, page int, perPage int, etag string) ([]*model.IncomingWebhook, *model.Response, error)
GetIncomingWebhooksForTeam(ctx context.Context, teamID string, page int, perPage int, etag string) ([]*model.IncomingWebhook, *model.Response, error)
GetIncomingWebhook(ctx context.Context, hookID string, etag string) (*model.IncomingWebhook, *model.Response, error)
DeleteIncomingWebhook(ctx context.Context, hookID string) (*model.Response, error)
CreateOutgoingWebhook(ctx context.Context, hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error)
UpdateOutgoingWebhook(ctx context.Context, hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error)
GetOutgoingWebhooks(ctx context.Context, page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error)
GetOutgoingWebhook(ctx context.Context, hookID string) (*model.OutgoingWebhook, *model.Response, error)
GetOutgoingWebhooksForChannel(ctx context.Context, channelID string, page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error)
GetOutgoingWebhooksForTeam(ctx context.Context, teamID string, page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error)
RegenOutgoingHookToken(ctx context.Context, hookID string) (*model.OutgoingWebhook, *model.Response, error)
DeleteOutgoingWebhook(ctx context.Context, hookID string) (*model.Response, error)
ListExports(ctx context.Context) ([]string, *model.Response, error)
DeleteExport(ctx context.Context, name string) (*model.Response, error)
DownloadExport(ctx context.Context, name string, wr io.Writer, offset int64) (int64, *model.Response, error)
DownloadComplianceExport(ctx context.Context, jobID string, wr io.Writer) (string, error)
GeneratePresignedURL(ctx context.Context, name string) (*model.PresignURLResponse, *model.Response, error)
ResetSamlAuthDataToEmail(ctx context.Context, includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error)
GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *model.Response, error)
GetOAuthApps(ctx context.Context, page, perPage int) ([]*model.OAuthApp, *model.Response, error)
GetPreferences(ctx context.Context, userId string) (model.Preferences, *model.Response, error)
GetPreferencesByCategory(ctx context.Context, userId, category string) (model.Preferences, *model.Response, error)
GetPreferenceByCategoryAndName(ctx context.Context, userId, category, preferenceName string) (*model.Preference, *model.Response, error)
UpdatePreferences(ctx context.Context, userId string, preferences model.Preferences) (*model.Response, error)
DeletePreferences(ctx context.Context, userId string, preferences model.Preferences) (*model.Response, error)
PermanentDeletePost(ctx context.Context, postID string) (*model.Response, error)
DeletePost(ctx context.Context, postId string) (*model.Response, error)
ListCPAFields(ctx context.Context) ([]*model.PropertyField, *model.Response, error)
CreateCPAField(ctx context.Context, field *model.PropertyField) (*model.PropertyField, *model.Response, error)
PatchCPAField(ctx context.Context, fieldID string, patch *model.PropertyFieldPatch) (*model.PropertyField, *model.Response, error)
DeleteCPAField(ctx context.Context, fieldID string) (*model.Response, error)
ListCPAValues(ctx context.Context, userID string) (map[string]json.RawMessage, *model.Response, error)
PatchCPAValues(ctx context.Context, values map[string]json.RawMessage) (map[string]json.RawMessage, *model.Response, error)
PatchCPAValuesForUser(ctx context.Context, userID string, values map[string]json.RawMessage) (map[string]json.RawMessage, *model.Response, error)
GetPostsForReporting(ctx context.Context, options model.ReportPostOptions, cursor model.ReportPostOptionsCursor) (*model.ReportPostListResponse, *model.Response, error)
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bufio"
"context"
"fmt"
"net/http"
"net/url"
"os"
"slices"
"strings"
"syscall"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
var AuthCmd = &cobra.Command{
Use: "auth",
Short: "Manages the credentials of the remote Mattermost instances",
}
var LoginCmd = &cobra.Command{
Use: "login [instance url] --name [server name] --username [username] --password-file [password-file]",
Short: "Login into an instance",
Long: "Login into an instance and store credentials",
Example: ` auth login https://mattermost.example.com
auth login https://mattermost.example.com --name local-server --username sysadmin --password-file mysupersecret.txt
auth login https://mattermost.example.com --name local-server --username sysadmin --password-file mysupersecret.txt --mfa-token 123456
auth login https://mattermost.example.com --name local-server --access-token myaccesstoken`,
Args: cobra.ExactArgs(1),
RunE: loginCmdF,
}
var CurrentCmd = &cobra.Command{
Use: "current",
Short: "Show current user credentials",
Long: "Show the currently stored user credentials",
Example: ` auth current`,
RunE: currentCmdF,
}
var SetCmd = &cobra.Command{
Use: "set [server name]",
Short: "Set the credentials to use",
Long: "Set an credentials to use in the following commands",
Example: ` auth set local-server`,
Args: cobra.ExactArgs(1),
RunE: setCmdF,
}
var ListCmd = &cobra.Command{
Use: "list",
Short: "Lists the credentials",
Long: "Print a list of the registered credentials",
Example: ` auth list`,
RunE: listCmdF,
}
var RenewCmd = &cobra.Command{
Use: "renew",
Short: "Renews a set of credentials",
Long: "Renews the credentials for a given server",
Example: ` auth renew local-server`,
Args: cobra.ExactArgs(1),
RunE: renewCmdF,
}
var DeleteCmd = &cobra.Command{
Use: "delete [server name]",
Short: "Delete an credentials",
Long: "Delete an credentials by its name",
Example: ` auth delete local-server`,
Args: cobra.ExactArgs(1),
RunE: deleteCmdF,
}
var CleanCmd = &cobra.Command{
Use: "clean",
Short: "Clean all credentials",
Long: "Clean the currently stored credentials",
Example: ` auth clean`,
RunE: cleanCmdF,
}
func init() {
LoginCmd.Flags().StringP("name", "n", "", "Name for the credentials")
LoginCmd.Flags().StringP("username", "u", "", "Username for the credentials")
LoginCmd.Flags().StringP("access-token", "a", "", "Access token to use instead of username/password")
_ = LoginCmd.Flags().MarkHidden("access-token")
LoginCmd.Flags().StringP("access-token-file", "t", "", "Access token file to be read to use instead of username/password")
LoginCmd.Flags().StringP("mfa-token", "m", "", "MFA token for the credentials")
LoginCmd.Flags().StringP("password", "p", "", "Password for the credentials")
_ = LoginCmd.Flags().MarkHidden("password")
LoginCmd.Flags().StringP("password-file", "f", "", "Password file to be read for the credentials")
LoginCmd.Flags().Bool("no-activate", false, "If present, it won't activate the credentials after login")
RenewCmd.Flags().StringP("password", "p", "", "Password for the credentials")
_ = RenewCmd.Flags().MarkHidden("password")
RenewCmd.Flags().StringP("password-file", "f", "", "Password file to be read for the credentials")
RenewCmd.Flags().StringP("access-token", "a", "", "Access token to use instead of username/password")
_ = RenewCmd.Flags().MarkHidden("access-token")
RenewCmd.Flags().StringP("access-token-file", "t", "", "Access token file to be read to use instead of username/password")
RenewCmd.Flags().StringP("mfa-token", "m", "", "MFA token for the credentials")
AuthCmd.AddCommand(
LoginCmd,
CurrentCmd,
SetCmd,
ListCmd,
RenewCmd,
DeleteCmd,
CleanCmd,
)
RootCmd.AddCommand(AuthCmd)
}
func loginCmdF(cmd *cobra.Command, args []string) error {
name, err := cmd.Flags().GetString("name")
if err != nil {
return err
}
username, err := cmd.Flags().GetString("username")
if err != nil {
return err
}
password, err := cmd.Flags().GetString("password")
if err != nil {
return err
}
passwordFile, _ := cmd.Flags().GetString("password-file")
if password != "" && passwordFile != "" {
return errors.New("cannot use two passwords at the same time")
}
if fErr := readSecretFromFile(passwordFile, &password); fErr != nil {
return fmt.Errorf("could not read the password: %w", fErr)
}
accessToken, err := cmd.Flags().GetString("access-token")
if err != nil {
return err
}
accessTokenFile, _ := cmd.Flags().GetString("access-token-file")
if accessToken != "" && accessTokenFile != "" {
return errors.New("cannot use two access tokens at the same time")
}
if fErr := readSecretFromFile(accessTokenFile, &accessToken); fErr != nil {
return fmt.Errorf("could not read the access-token: %w", fErr)
}
mfaToken, err := cmd.Flags().GetString("mfa-token")
if err != nil {
return err
}
allowInsecureSHA1 := viper.GetBool("insecure-sha1-intermediate")
allowInsecureTLS := viper.GetBool("insecure-tls-version")
instanceURL := strings.TrimRight(args[0], "/")
_, err = url.ParseRequestURI(instanceURL)
if err != nil {
return fmt.Errorf("could not parse the instance url: %w", err)
}
res, err := http.Get(instanceURL)
if err != nil {
return fmt.Errorf("could not get instance status: %w", err)
}
if res.StatusCode != 200 {
return fmt.Errorf("instance status code is not 200: %d", res.StatusCode)
}
method := MethodPassword
ctx := context.TODO()
if name == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("Connection name: ")
name, err = reader.ReadString('\n')
if err != nil {
return err
}
name = strings.TrimSpace(name)
}
if accessToken != "" && username != "" {
return errors.New("you must use --access-token or --username, but not both")
}
if accessToken == "" && username == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("Username: ")
username, err = reader.ReadString('\n')
if err != nil {
return err
}
username = strings.TrimSpace(username)
}
if username != "" && password == "" {
fmt.Printf("Password: ")
stdinPassword, err := getPasswordFromStdin()
if err != nil {
return errors.WithMessage(err, "couldn't read password")
}
password = stdinPassword
}
if username != "" {
var c *model.Client4
var err error
if mfaToken != "" {
c, _, err = InitClientWithMFA(ctx, username, password, mfaToken, instanceURL, allowInsecureSHA1, allowInsecureTLS)
method = MethodMFA
} else {
c, _, err = InitClientWithUsernameAndPassword(ctx, username, password, instanceURL, allowInsecureSHA1, allowInsecureTLS)
}
if err != nil {
return fmt.Errorf("could not initiate client: %w", err)
}
accessToken = c.AuthToken
} else {
username = "Personal Access Token"
method = MethodToken
credentials := Credentials{
InstanceURL: instanceURL,
AuthToken: accessToken,
}
if _, _, err := InitClientWithCredentials(ctx, &credentials, allowInsecureSHA1, allowInsecureTLS); err != nil {
return fmt.Errorf("could not initiate client: %w", err)
}
}
credentials := Credentials{
Name: name,
InstanceURL: instanceURL,
Username: username,
AuthToken: accessToken,
AuthMethod: method,
}
if err := SaveCredentials(credentials); err != nil {
return err
}
noActivate, _ := cmd.Flags().GetBool("no-activate")
if !noActivate {
if err := SetCurrent(name); err != nil {
return err
}
}
printer.Print(fmt.Sprintf("\n credentials for %q: \"%s@%s\" stored\n", name, username, instanceURL))
return nil
}
func getPasswordFromStdin() (string, error) {
// syscall.Stdin is of type int in all architectures but in
// windows, so we have to cast it to ensure cross compatibility
//nolint:unconvert
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println("")
if err != nil {
return "", err
}
return string(bytePassword), nil
}
func currentCmdF(cmd *cobra.Command, args []string) error {
credentials, err := GetCurrentCredentials()
if err != nil {
return err
}
printer.Print(fmt.Sprintf("\n found credentials for %q: \"%s@%s\"\n", credentials.Name, credentials.Username, credentials.InstanceURL))
return nil
}
func setCmdF(cmd *cobra.Command, args []string) error {
if err := SetCurrent(args[0]); err != nil {
return err
}
printer.Print(fmt.Sprintf("Credentials for server %q set as active", args[0]))
return nil
}
func listCmdF(cmd *cobra.Command, args []string) error {
credentialsList, err := ReadCredentialsList()
if err != nil {
return err
}
if len(*credentialsList) == 0 {
return errors.New("there are no registered credentials, maybe you need to use login first")
}
serverNames := []string{}
nameTitle, usernameTitle, instanceURLTitle := "Name", "Username", "InstanceURL"
maxNameLen, maxUsernameLen, maxInstanceURLLen := len(nameTitle), len(usernameTitle), len(instanceURLTitle)
for _, c := range *credentialsList {
serverNames = append(serverNames, c.Name)
if maxNameLen <= len(c.Name) {
maxNameLen = len(c.Name)
}
if maxUsernameLen <= len(c.Username) {
maxUsernameLen = len(c.Username)
}
if maxInstanceURLLen <= len(c.InstanceURL) {
maxInstanceURLLen = len(c.InstanceURL)
}
}
slices.Sort(serverNames)
printer.Print(fmt.Sprintf("\n | Active | %*s | %*s | %*s |", maxNameLen, nameTitle, maxUsernameLen, usernameTitle, maxInstanceURLLen, instanceURLTitle))
printer.Print(fmt.Sprintf(" |%s|%s|%s|%s|", strings.Repeat("-", 8), strings.Repeat("-", maxNameLen+2), strings.Repeat("-", maxUsernameLen+2), strings.Repeat("-", maxInstanceURLLen+2)))
for _, name := range serverNames {
c := (*credentialsList)[name]
if c.Active {
printer.Print(fmt.Sprintf(" | * | %*s | %*s | %*s |", maxNameLen, c.Name, maxUsernameLen, c.Username, maxInstanceURLLen, c.InstanceURL))
} else {
printer.Print(fmt.Sprintf(" | | %*s | %*s | %*s |", maxNameLen, c.Name, maxUsernameLen, c.Username, maxInstanceURLLen, c.InstanceURL))
}
}
printer.Print("")
return nil
}
func renewCmdF(cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
password, _ := cmd.Flags().GetString("password")
passwordFile, _ := cmd.Flags().GetString("password-file")
if password != "" && passwordFile != "" {
return errors.New("cannot use two passwords at the same time")
}
if fErr := readSecretFromFile(passwordFile, &password); fErr != nil {
return fmt.Errorf("could not read the password: %w", fErr)
}
accessToken, _ := cmd.Flags().GetString("access-token")
accessTokenFile, _ := cmd.Flags().GetString("access-token-file")
if accessToken != "" && accessTokenFile != "" {
return errors.New("cannot use two access tokens at the same time")
}
if fErr := readSecretFromFile(accessTokenFile, &accessToken); fErr != nil {
return fmt.Errorf("could not read the access-token: %w", fErr)
}
mfaToken, _ := cmd.Flags().GetString("mfa-token")
allowInsecureSHA1 := viper.GetBool("insecure-sha1-intermediate")
allowInsecureTLS := viper.GetBool("insecure-tls-version")
credentials, err := GetCredentials(args[0])
if err != nil {
return err
}
ctx := context.TODO()
if (credentials.AuthMethod == MethodPassword || credentials.AuthMethod == MethodMFA) && password == "" {
if password == "" {
fmt.Printf("Password: ")
stdinPassword, err := getPasswordFromStdin()
if err != nil {
return errors.WithMessage(err, "couldn't read password")
}
password = stdinPassword
}
}
switch credentials.AuthMethod {
case MethodPassword:
c, _, err := InitClientWithUsernameAndPassword(ctx, credentials.Username, password, credentials.InstanceURL, allowInsecureSHA1, allowInsecureTLS)
if err != nil {
return err
}
credentials.AuthToken = c.AuthToken
case MethodToken:
if accessToken == "" {
return errors.New("requires the --access-token parameter to be set")
}
credentials.AuthToken = accessToken
if _, _, err := InitClientWithCredentials(ctx, credentials, allowInsecureSHA1, allowInsecureTLS); err != nil {
return err
}
case MethodMFA:
if mfaToken == "" {
return errors.New("requires the --mfa-token parameter to be set")
}
c, _, err := InitClientWithMFA(ctx, credentials.Username, password, mfaToken, credentials.InstanceURL, allowInsecureSHA1, allowInsecureTLS)
if err != nil {
return err
}
credentials.AuthToken = c.AuthToken
default:
return errors.Errorf("invalid auth method %q", credentials.AuthMethod)
}
if err := SaveCredentials(*credentials); err != nil {
return err
}
printer.PrintT("Credentials for server \"{{.Name}}\" successfully renewed", credentials)
return nil
}
func deleteCmdF(cmd *cobra.Command, args []string) error {
credentialsList, err := ReadCredentialsList()
if err != nil {
return err
}
name := args[0]
credentials := (*credentialsList)[name]
if credentials == nil {
return errors.Errorf("cannot find credentials for server name %q", name)
}
delete(*credentialsList, name)
return SaveCredentialsList(credentialsList)
}
func cleanCmdF(cmd *cobra.Command, args []string) error {
if err := CleanCredentials(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestAuthLoginWithTrailingSlashInInstanceURL() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("URL with trailing slash", func() {
// loginCmdf doesn't return an error in this case. It prints to stderr instead.
printer.Clean()
// cobra wont let us run a subcommand directly. When we try to `Execute`
// the subcommand, cobra executes the parent command.
// Instead of calling RootCmd, with its various subcommands and options,
// we duplicate part of the LoginCmd here.
cmd := &cobra.Command{}
cmd.Flags().StringP("name", "n", "name", "Name for the credentials")
cmd.Flags().StringP("username", "u", s.th.BasicUser.Username, "Username for the credentials")
cmd.Flags().StringP("password", "p", s.th.BasicUser.Password, "Password for the credentials")
cmd.Flags().StringP("access-token", "a", "", "Access token to use instead of username/password")
cmd.Flags().StringP("mfa-token", "m", "", "MFA token for the credentials")
cmd.Flags().Bool("no-activate", false, "If present, it won't activate the credentials after login")
_ = loginCmdF(cmd, []string{s.th.Client.URL + "/"}) // add a trailing slash
errLines := printer.GetErrorLines()
s.Require().Lenf(errLines, 0, "expected no error, got %q", errLines)
})
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"os/user"
"path/filepath"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (s *MmctlUnitTestSuite) TestAuthList() {
originalUser := *currentUser
defer func() {
SetUser(&originalUser)
}()
tmp, errMkDir := os.MkdirTemp("", "mmctl-")
s.Require().NoError(errMkDir)
s.T().Cleanup(func() {
s.Require().NoError(os.RemoveAll(tmp))
})
testUser, err := user.Current()
s.Require().NoError(err)
testUser.HomeDir = tmp
SetUser(testUser)
viper.Set("config", filepath.Join(xdgConfigHomeVar, configParent, configFileName))
s.Run("short name", func() {
printer.Clean()
credentials := Credentials{
Name: "MyN",
Username: "Un",
AuthToken: "",
AuthMethod: "",
InstanceURL: "IURL",
Active: true,
}
err = CleanCredentials()
s.Require().NoError(err)
err = SaveCredentials(credentials)
s.Require().NoError(err)
errListCmdF := listCmdF(&cobra.Command{}, []string{})
s.Require().NoError(errListCmdF)
s.Require().Len(printer.GetErrorLines(), 0)
lines := printer.GetLines()
s.Require().Len(lines, 4)
s.Require().Contains(lines[2], "| * | MyN | Un | IURL |")
s.Require().Contains(lines[1], "|--------|------|----------|-------------|")
s.Require().Contains(lines[0], "| Active | Name | Username | InstanceURL |")
})
s.Run("normal name", func() {
printer.Clean()
credentials := Credentials{
Name: "MyName",
Username: "MyUsername",
AuthToken: "",
AuthMethod: "",
InstanceURL: "My Instance URL",
Active: true,
}
err = CleanCredentials()
s.Require().NoError(err)
err = SaveCredentials(credentials)
s.Require().NoError(err)
errListCmdF := listCmdF(&cobra.Command{}, []string{})
s.Require().NoError(errListCmdF)
s.Require().Len(printer.GetErrorLines(), 0)
lines := printer.GetLines()
s.Require().Len(lines, 4)
s.Require().Contains(lines[2], "| * | MyName | MyUsername | My Instance URL |")
s.Require().Contains(lines[1], "|--------|--------|------------|-----------------|")
s.Require().Contains(lines[0], "| Active | Name | Username | InstanceURL |")
})
}

View File

@@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"encoding/json"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/spf13/viper"
)
const (
MethodPassword = "P"
MethodToken = "T"
MethodMFA = "M"
userHomeVar = "$HOME"
configFileName = "config"
configParent = "mmctl"
xdgConfigHomeVar = "$XDG_CONFIG_HOME"
)
type Credentials struct {
Name string `json:"name"`
Username string `json:"username"`
AuthToken string `json:"authToken"`
AuthMethod string `json:"authMethod"`
InstanceURL string `json:"instanceUrl"`
Active bool `json:"active"`
}
type CredentialsList map[string]*Credentials
var currentUser *user.User
func init() {
newUser, err := user.Current()
if err != nil {
panic(err)
}
SetUser(newUser)
}
func getDefaultConfigHomePath() string {
if p, ok := os.LookupEnv(strings.TrimPrefix(xdgConfigHomeVar, "$")); ok {
return p
}
return filepath.Join(currentUser.HomeDir, ".config")
}
func resolveConfigFilePath() string {
// resolve env vars if there are any
fpath := strings.Replace(viper.GetString("config"), userHomeVar, currentUser.HomeDir, 1)
return strings.Replace(fpath, xdgConfigHomeVar, getDefaultConfigHomePath(), 1)
}
func ReadCredentialsList() (*CredentialsList, error) {
configPath := resolveConfigFilePath()
if _, err := os.Stat(configPath); err != nil {
return nil, errors.WithMessage(err, "cannot read user credentials, maybe you need to use login first")
}
fileContents, err := os.ReadFile(configPath)
if err != nil {
return nil, errors.WithMessage(err, "there was a problem reading the credentials file")
}
var credentialsList CredentialsList
if err := json.Unmarshal(fileContents, &credentialsList); err != nil {
return nil, errors.WithMessage(err, "there was a problem parsing the credentials file")
}
return &credentialsList, nil
}
func GetCurrentCredentials() (*Credentials, error) {
credentialsList, err := ReadCredentialsList()
if err != nil {
return nil, err
}
for _, c := range *credentialsList {
if c.Active {
return c, nil
}
}
return nil, errors.Errorf("no current context available. please use the %q command", "auth set")
}
func GetCredentials(name string) (*Credentials, error) {
credentialsList, err := ReadCredentialsList()
if err != nil {
return nil, err
}
for _, c := range *credentialsList {
if c.Name == name {
return c, nil
}
}
return nil, errors.Errorf("couldn't find credentials for connection %q", name)
}
func SaveCredentials(credentials Credentials) error {
credentialsList, err := ReadCredentialsList()
if err != nil {
// we get the parent of the file so that we can create the path if it doesn't exist.
configParent := filepath.Dir(resolveConfigFilePath())
if err := os.MkdirAll(configParent, 0700); err != nil {
return err
}
credentialsList = &CredentialsList{}
credentials.Active = true
}
(*credentialsList)[credentials.Name] = &credentials
return SaveCredentialsList(credentialsList)
}
func SaveCredentialsList(credentialsList *CredentialsList) error {
configPath := resolveConfigFilePath()
marshaledCredentialsList, _ := json.MarshalIndent(credentialsList, "", " ")
if err := os.WriteFile(configPath, marshaledCredentialsList, 0600); err != nil {
return errors.WithMessage(err, "cannot save the credentials")
}
return nil
}
func SetCurrent(name string) error {
credentialsList, err := ReadCredentialsList()
if err != nil {
return err
}
found := false
for _, c := range *credentialsList {
if c.Name == name {
found = true
c.Active = true
} else {
c.Active = false
}
}
if !found {
return errors.Errorf("cannot find credentials for server %q", name)
}
return SaveCredentialsList(credentialsList)
}
func CleanCredentials() error {
configFilePath := resolveConfigFilePath()
if _, err := os.Stat(configFilePath); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if err := os.Remove(configFilePath); err != nil {
return err
}
return nil
}
func SetUser(newUser *user.User) {
currentUser = newUser
}
// will read the scret from file, if there is one
func readSecretFromFile(file string, secret *string) error {
if file != "" {
b, err := os.ReadFile(file)
if err != nil {
return err
}
*secret = strings.TrimSpace(string(b))
}
return nil
}

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveConfigFilePath(t *testing.T) {
originalUser := *currentUser
defer func() {
SetUser(&originalUser)
}()
testUser, err := user.Current()
require.NoError(t, err)
t.Run("should return the default config file location if nothing else is set", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp
SetUser(testUser)
expected := filepath.Join(getDefaultConfigHomePath(), configParent, configFileName)
viper.Set("config", filepath.Join(xdgConfigHomeVar, configParent, configFileName))
p := resolveConfigFilePath()
require.Equal(t, expected, p)
})
t.Run("should return config file location from xdg environment variable", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp
SetUser(testUser)
expected := filepath.Join(testUser.HomeDir, ".config", configParent, configFileName)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(testUser.HomeDir, ".config"))
viper.Set("config", filepath.Join(xdgConfigHomeVar, configParent, configFileName))
p := resolveConfigFilePath()
require.Equal(t, expected, p)
})
t.Run("should return the user-defined config file path if one is set", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp + "/path/should/be/ignored"
SetUser(testUser)
expected := filepath.Join(tmp, configFileName)
t.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored")
viper.Set("config", expected)
p := resolveConfigFilePath()
require.Equal(t, expected, p)
})
t.Run("should resolve config file path if $HOME variable is used", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp + "/path/should/be/ignored"
SetUser(testUser)
expected := filepath.Join(testUser.HomeDir, "/.config/mmctl/config")
t.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored")
viper.Set("config", "$HOME/.config/mmctl/config")
p := resolveConfigFilePath()
require.Equal(t, expected, p)
})
t.Run("should create the user-defined config file path if one is set", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp + "path/should/be/ignored"
SetUser(testUser)
extraDir := "extra"
expected := filepath.Join(tmp, extraDir, "config.json")
t.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored")
viper.Set("config", expected)
err = SaveCredentials(Credentials{})
require.NoError(t, err)
info, err := os.Stat(expected)
require.NoError(t, err)
assert.False(t, info.IsDir())
assert.True(t, info.Name() == "config.json")
})
t.Run("should return error if the config flag is set to a directory", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "mmctl-")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(tmp)
require.NoError(t, err)
})
testUser.HomeDir = tmp + "path/should/be/ignored"
SetUser(testUser)
t.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored")
viper.Set("config", tmp)
err = SaveCredentials(Credentials{})
require.Error(t, err)
require.True(t, strings.HasSuffix(err.Error(), "is a directory"))
})
}
func TestReadSecretFromFile(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "mmctl")
require.NoError(t, err)
_, err = f.WriteString("test-pass")
require.NoError(t, err)
t.Run("password from file", func(t *testing.T) {
var pass string
err := readSecretFromFile(f.Name(), &pass)
require.NoError(t, err)
require.Equal(t, "test-pass", pass)
})
t.Run("no file path is provided", func(t *testing.T) {
pass := "test-pass-2"
err := readSecretFromFile("", &pass)
require.NoError(t, err)
require.Equal(t, "test-pass-2", pass)
})
t.Run("nonexistent file provided", func(t *testing.T) {
var pass string
err := readSecretFromFile(filepath.Join(t.TempDir(), "bla"), &pass)
require.Error(t, err)
require.Empty(t, pass)
})
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var BotCmd = &cobra.Command{
Use: "bot",
Short: "Management of bots",
}
var CreateBotCmd = &cobra.Command{
Use: "create [username]",
Short: "Create bot",
Long: "Create bot.",
Example: ` bot create testbot`,
PreRun: disableLocalPrecheck,
RunE: withClient(botCreateCmdF),
Args: cobra.ExactArgs(1),
}
var UpdateBotCmd = &cobra.Command{
Use: "update [username]",
Short: "Update bot",
Long: "Update bot information.",
Example: ` bot update testbot --username newbotusername`,
RunE: withClient(botUpdateCmdF),
Args: cobra.ExactArgs(1),
}
var ListBotCmd = &cobra.Command{
Use: "list",
Short: "List bots",
Long: "List the bots users.",
Example: ` bot list`,
RunE: withClient(botListCmdF),
Args: cobra.NoArgs,
}
var DisableBotCmd = &cobra.Command{
Use: "disable [username]",
Short: "Disable bot",
Long: "Disable an enabled bot",
Example: ` bot disable testbot`,
RunE: withClient(botDisableCmdF),
Args: cobra.MinimumNArgs(1),
}
var EnableBotCmd = &cobra.Command{
Use: "enable [username]",
Short: "Enable bot",
Long: "Enable a disabled bot",
Example: ` bot enable testbot`,
RunE: withClient(botEnableCmdF),
Args: cobra.MinimumNArgs(1),
}
var AssignBotCmd = &cobra.Command{
Use: "assign [bot-username] [new-owner-username]",
Short: "Assign bot",
Long: "Assign the ownership of a bot to another user",
Example: ` bot assign testbot user2`,
RunE: withClient(botAssignCmdF),
Args: cobra.ExactArgs(2),
}
func init() {
CreateBotCmd.Flags().String("display-name", "", "Optional. The display name for the new bot.")
CreateBotCmd.Flags().String("description", "", "Optional. The description text for the new bot.")
CreateBotCmd.Flags().Bool("with-token", false, "Optional. Auto genreate access token for the bot.")
ListBotCmd.Flags().Bool("orphaned", false, "Optional. Only show orphaned bots.")
ListBotCmd.Flags().Bool("all", false, "Optional. Show all bots (including deleleted and orphaned).")
UpdateBotCmd.Flags().String("username", "", "Optional. The new username for the bot.")
UpdateBotCmd.Flags().String("display-name", "", "Optional. The new display name for the bot.")
UpdateBotCmd.Flags().String("description", "", "Optional. The new description text for the bot.")
BotCmd.AddCommand(
CreateBotCmd,
UpdateBotCmd,
ListBotCmd,
EnableBotCmd,
DisableBotCmd,
AssignBotCmd,
)
RootCmd.AddCommand(BotCmd)
}
func botCreateCmdF(c client.Client, cmd *cobra.Command, args []string) error {
username := args[0]
displayName, _ := cmd.Flags().GetString("display-name")
description, _ := cmd.Flags().GetString("description")
bot, _, err := c.CreateBot(context.TODO(), &model.Bot{
Username: username,
DisplayName: displayName,
Description: description,
})
if err != nil {
return errors.Errorf("could not create bot: %s", err)
}
printer.PrintT("Created bot {{.UserId}}", bot)
if withToken, _ := cmd.Flags().GetBool("with-token"); withToken {
return generateTokenForAUserCmdF(c, cmd, []string{args[0], "autogenerated"})
}
return nil
}
func botUpdateCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("username") && !cmd.Flags().Changed("display-name") && !cmd.Flags().Changed("description") {
return errors.New("at least one of --username, --display-name or --description must be set")
}
user := getUserFromUserArg(c, args[0])
if user == nil {
return errors.New("unable to find user '" + args[0] + "'")
}
patch := model.BotPatch{}
username, err := cmd.Flags().GetString("username")
if err == nil && cmd.Flags().Changed("username") {
patch.Username = &username
}
displayName, err := cmd.Flags().GetString("display-name")
if err == nil && cmd.Flags().Changed("display-name") {
patch.DisplayName = &displayName
}
description, err := cmd.Flags().GetString("description")
if err == nil && cmd.Flags().Changed("description") {
patch.Description = &description
}
bot, _, err := c.PatchBot(context.TODO(), user.Id, &patch)
if err != nil {
return errors.Errorf("could not update bot: %s", err)
}
printer.PrintT("Updated bot {{.UserId}} ({{.Username}})", bot)
return nil
}
func botListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
orphaned, _ := cmd.Flags().GetBool("orphaned")
all, _ := cmd.Flags().GetBool("all")
page := 0
perPage := DefaultPageSize
tpl := `{{.UserId}}: {{.Username}}`
for {
var bots []*model.Bot
var err error
if all { //nolint:gocritic
bots, _, err = c.GetBotsIncludeDeleted(context.TODO(), page, perPage, "")
} else if orphaned {
bots, _, err = c.GetBotsOrphaned(context.TODO(), page, perPage, "")
} else {
bots, _, err = c.GetBots(context.TODO(), page, perPage, "")
}
if err != nil {
return errors.Wrap(err, "Failed to fetch bots")
}
userIds := []string{}
for _, bot := range bots {
userIds = append(userIds, bot.OwnerId)
}
users, _, err := c.GetUsersByIds(context.TODO(), userIds)
if err != nil {
return errors.Wrap(err, "Failed to fetch bots")
}
usersByID := map[string]*model.User{}
for _, user := range users {
usersByID[user.Id] = user
}
var ownerName string
var ownerDeleteAt int64
for _, bot := range bots {
if owner, ok := usersByID[bot.OwnerId]; ok {
ownerName = owner.Username
ownerDeleteAt = owner.DeleteAt
} else {
// not all bots have a userId in their ownerId field.
ownerName = bot.OwnerId
ownerDeleteAt = 0
}
tplExtraText := fmt.Sprintf("(Owned by %s, {{if ne .DeleteAt 0}}Disabled{{else}}Enabled{{end}}{{if ne %d 0}}, Orphaned{{end}})", ownerName, ownerDeleteAt)
printer.PrintT(tpl+tplExtraText, bot)
}
if len(bots) < perPage {
break
}
page++
}
return nil
}
func botEnableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
users := getUsersFromUserArgs(c, args)
var result *multierror.Error
for i, user := range users {
if user == nil {
printer.PrintError(fmt.Sprintf("can't find user '%v'", args[i]))
result = multierror.Append(result, fmt.Errorf("can't find user %q", args[i]))
continue
}
bot, _, err := c.EnableBot(context.TODO(), user.Id)
if err != nil {
printer.PrintError(fmt.Sprintf("could not enable bot '%v'", args[i]))
result = multierror.Append(result, fmt.Errorf("could not enable bot %q: %w", args[i], err))
continue
}
printer.PrintT("Enabled bot {{.UserId}} ({{.Username}})", bot)
}
return result.ErrorOrNil()
}
func botDisableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
users := getUsersFromUserArgs(c, args)
var result *multierror.Error
for i, user := range users {
if user == nil {
printer.PrintError(fmt.Sprintf("can't find user '%v'", args[i]))
result = multierror.Append(result, fmt.Errorf("can't find user %q", args[i]))
continue
}
bot, _, err := c.DisableBot(context.TODO(), user.Id)
if err != nil {
printer.PrintError(fmt.Sprintf("could not disable bot '%v'", args[i]))
result = multierror.Append(result, fmt.Errorf("could not disable bot %q: %w", args[i], err))
continue
}
printer.PrintT("Disabled bot {{.UserId}} ({{.Username}})", bot)
}
return result.ErrorOrNil()
}
func botAssignCmdF(c client.Client, cmd *cobra.Command, args []string) error {
botUser := getUserFromUserArg(c, args[0])
if botUser == nil {
return errors.New("unable to find user '" + args[0] + "'")
}
newOwnerUser := getUserFromUserArg(c, args[1])
if newOwnerUser == nil {
return errors.New("unable to find user '" + args[1] + "'")
}
newBot, _, err := c.AssignBot(context.TODO(), botUser.Id, newOwnerUser.Id)
if err != nil {
return errors.Errorf("can not assign bot '%s' to user '%s'", args[0], args[1])
}
printer.PrintT("The bot {{.UserId}} ({{.Username}}) now belongs to the user "+newOwnerUser.Username, newBot)
return nil
}

View File

@@ -0,0 +1,434 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestListBotCmdF() {
s.SetupTestHelper().InitBasic(s.T()).DeleteBots(s.T())
s.RunForSystemAdminAndLocal("List Bot", func(c client.Client) {
printer.Clean()
bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: s.th.BasicUser.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: s.th.BasicUser.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, deletedBot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr = s.th.App.UpdateBotActive(s.th.Context, deletedBot.UserId, false)
s.Require().Nil(appErr)
err := botListCmdF(c, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
listedBot, ok := printer.GetLines()[0].(*model.Bot)
s.Require().True(ok)
s.Require().Equal(bot.Username, listedBot.Username)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("List Bot only orphaned", func(c client.Client) {
printer.Clean()
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId(), DeleteAt: 1})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, user)
s.Require().Nil(err)
}()
bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: s.th.BasicUser.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, deletedBot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr = s.th.App.UpdateBotActive(s.th.Context, deletedBot.UserId, false)
s.Require().Nil(appErr)
orphanBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, orphanBot.UserId)
s.Require().Nil(err)
}()
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", true, "")
err := botListCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
listedBot, ok := printer.GetLines()[0].(*model.Bot)
s.Require().True(ok)
s.Require().Equal(orphanBot.Username, listedBot.Username)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("List all Bots", func(c client.Client) {
printer.Clean()
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId(), DeleteAt: 1})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, user)
s.Require().Nil(err)
}()
bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: s.th.BasicUser2.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
orphanBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, orphanBot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: s.th.BasicUser2.Id})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, deletedBot.UserId)
s.Require().Nil(err)
}()
deletedBot, appErr = s.th.App.UpdateBotActive(s.th.Context, deletedBot.UserId, false)
s.Require().Nil(appErr)
cmd := &cobra.Command{}
cmd.Flags().Bool("all", true, "")
err := botListCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Equal(3, len(printer.GetLines()))
resultBot, ok := printer.GetLines()[0].(*model.Bot)
s.True(ok)
s.Require().Equal(bot, resultBot)
resultOrphanBot, ok := printer.GetLines()[1].(*model.Bot)
s.True(ok)
s.Require().Equal(orphanBot, resultOrphanBot)
resultDeletedBot, ok := printer.GetLines()[2].(*model.Bot)
s.True(ok)
s.Require().Equal(deletedBot, resultDeletedBot)
})
s.Run("List Bots without permission", func() {
printer.Clean()
cmd := &cobra.Command{}
err := botListCmdF(s.th.Client, cmd, []string{})
s.Require().Error(err)
s.Require().Equal("Failed to fetch bots: You do not have the appropriate permissions.", err.Error())
})
}
func (s *MmctlE2ETestSuite) TestBotEnableCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableBotAccountCreation = true })
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("enable a bot", func(c client.Client) {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, false)
s.Require().Nil(appErr)
err := botEnableCmdF(c, &cobra.Command{}, []string{newBot.UserId})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
printedBot := printer.GetLines()[0].(*model.Bot)
s.Require().Equal(newBot.UserId, printedBot.UserId)
s.Require().Equal(newBot.Username, printedBot.Username)
s.Require().Equal(newBot.OwnerId, printedBot.OwnerId)
bot, appErr := s.th.App.GetBot(s.th.Context, newBot.UserId, false)
s.Require().Nil(appErr)
s.Require().Equal(newBot.UserId, bot.UserId)
s.Require().Equal(newBot.Username, bot.Username)
s.Require().Equal(newBot.OwnerId, bot.OwnerId)
})
s.Run("enable a bot without permissions", func() {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, false)
s.Require().Nil(appErr)
err := botEnableCmdF(s.th.Client, &cobra.Command{}, []string{newBot.UserId})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "could not enable bot")
})
s.RunForSystemAdminAndLocal("enable a nonexistent bot", func(c client.Client) {
printer.Clean()
err := botEnableCmdF(c, &cobra.Command{}, []string{"nonexistent-bot-userid"})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "can't find user 'nonexistent-bot-userid'")
})
s.RunForSystemAdminAndLocal("enable an already enabled bot", func(c client.Client) {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, true)
s.Require().Nil(appErr)
err := botEnableCmdF(c, &cobra.Command{}, []string{newBot.UserId})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
printedBot := printer.GetLines()[0].(*model.Bot)
s.Require().Equal(newBot.UserId, printedBot.UserId)
s.Require().Equal(newBot.Username, printedBot.Username)
s.Require().Equal(newBot.OwnerId, printedBot.OwnerId)
bot, appErr := s.th.App.GetBot(s.th.Context, newBot.UserId, false)
s.Require().Nil(appErr)
s.Require().Equal(newBot.UserId, bot.UserId)
s.Require().Equal(newBot.Username, bot.Username)
s.Require().Equal(newBot.OwnerId, bot.OwnerId)
})
}
func (s *MmctlE2ETestSuite) TestBotDisableCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableBotAccountCreation = true })
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("disable a bot", func(c client.Client) {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, true)
s.Require().Nil(appErr)
err := botDisableCmdF(c, &cobra.Command{}, []string{newBot.UserId})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
printedBot := printer.GetLines()[0].(*model.Bot)
s.Require().Equal(newBot.UserId, printedBot.UserId)
s.Require().Equal(newBot.Username, printedBot.Username)
s.Require().Equal(newBot.OwnerId, printedBot.OwnerId)
_, appErr = s.th.App.GetBot(s.th.Context, newBot.UserId, false)
s.Require().NotNil(appErr)
s.Require().Equal("store.sql_bot.get.missing.app_error", appErr.Id)
})
s.Run("disable a bot without permissions", func() {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, true)
s.Require().Nil(appErr)
err := botDisableCmdF(s.th.Client, &cobra.Command{}, []string{newBot.UserId})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "could not disable bot")
})
s.RunForSystemAdminAndLocal("disable a nonexistent bot", func(c client.Client) {
printer.Clean()
err := botDisableCmdF(c, &cobra.Command{}, []string{"nonexistent-bot-userid"})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "can't find user 'nonexistent-bot-userid'")
})
s.RunForSystemAdminAndLocal("disable an already disabled bot", func(c client.Client) {
printer.Clean()
newBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: user.Id})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpdateBotActive(s.th.Context, newBot.UserId, false)
s.Require().Nil(appErr)
err := botDisableCmdF(c, &cobra.Command{}, []string{newBot.UserId})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
printedBot := printer.GetLines()[0].(*model.Bot)
s.Require().Equal(newBot.UserId, printedBot.UserId)
s.Require().Equal(newBot.Username, printedBot.Username)
s.Require().Equal(newBot.OwnerId, printedBot.OwnerId)
_, appErr = s.th.App.GetBot(s.th.Context, newBot.UserId, false)
s.Require().NotNil(appErr)
s.Require().Equal("store.sql_bot.get.missing.app_error", appErr.Id)
})
}
func (s *MmctlE2ETestSuite) TestBotAssignCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Assign Bot", func(c client.Client) {
printer.Clean()
botOwner, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, botOwner)
s.Require().Nil(err)
}()
newBotOwner, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, newBotOwner)
s.Require().Nil(err)
}()
bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: botOwner.Id})
s.Require().Nil(appErr)
s.Require().Equal(bot.OwnerId, botOwner.Id)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
err := botAssignCmdF(c, &cobra.Command{}, []string{bot.UserId, newBotOwner.Id})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
newBot, ok := printer.GetLines()[0].(*model.Bot)
s.True(ok)
s.Require().Equal(newBotOwner.Id, newBot.OwnerId)
})
s.Run("Assign Bot without permission", func() {
printer.Clean()
botOwner, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, botOwner)
s.Require().Nil(err)
}()
newBotOwner, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteUser(s.th.Context, newBotOwner)
s.Require().Nil(err)
}()
bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewUsername(), OwnerId: botOwner.Id})
s.Require().Nil(appErr)
s.Require().Equal(bot.OwnerId, botOwner.Id)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
err := botAssignCmdF(s.th.Client, &cobra.Command{}, []string{bot.UserId, newBotOwner.Id})
s.Require().NotNil(err)
s.Require().EqualError(err, fmt.Sprintf(`can not assign bot '%s' to user '%s'`, bot.UserId, newBotOwner.Id), err.Error())
})
}
func (s *MmctlE2ETestSuite) TestBotCreateCmdF() {
s.SetupTestHelper().InitBasic(s.T())
createBots := *s.th.App.Config().ServiceSettings.EnableBotAccountCreation
s.th.App.UpdateConfig(func(c *model.Config) { *c.ServiceSettings.EnableBotAccountCreation = true })
defer s.th.App.UpdateConfig(func(c *model.Config) { *c.ServiceSettings.EnableBotAccountCreation = createBots })
s.Run("MM-T3941 Create Bot with an access token", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("with-token", true, "")
err := botCreateCmdF(s.th.Client, cmd, []string{"testbot"})
s.Require().Error(err)
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
printer.Clean()
err = botCreateCmdF(s.th.SystemAdminClient, cmd, []string{"testbot"})
s.Require().NoError(err)
s.Require().Equal(2, len(printer.GetLines()))
bot, ok := printer.GetLines()[0].(*model.Bot)
s.Require().True(ok)
defer func() {
err := s.th.App.PermanentDeleteBot(s.th.Context, bot.UserId)
s.Require().Nil(err)
}()
token, ok := printer.GetLines()[1].(*model.UserAccessToken)
s.Require().True(ok)
defer func() {
err := s.th.App.RevokeUserAccessToken(s.th.Context, token)
s.Require().Nil(err)
}()
s.Require().Empty(printer.GetErrorLines())
})
}

View File

@@ -0,0 +1,670 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
gomock "github.com/golang/mock/gomock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestBotCreateCmd() {
s.Run("Should create a bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
mockBot := model.Bot{Username: botArg, DisplayName: "some-name", Description: "some-text"}
s.client.
EXPECT().
CreateBot(context.TODO(), &mockBot).
Return(&mockBot, &model.Response{}, nil).
Times(1)
err := botCreateCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should create a bot with an access token", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
cmd.Flags().Bool("with-token", true, "")
mockBot := model.Bot{Username: botArg, DisplayName: "some-name", Description: "some-text"}
mockToken := model.UserAccessToken{Token: "token-id", Description: "autogenerated"}
s.client.
EXPECT().
CreateBot(context.TODO(), &mockBot).
Return(&mockBot, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(model.UserFromBot(&mockBot), &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUserAccessToken(context.TODO(), mockBot.UserId, "autogenerated", int64(0)).
Return(&mockToken, &model.Response{}, nil).
Times(1)
err := botCreateCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(&mockBot, printer.GetLines()[0])
s.Require().Equal(&mockToken, printer.GetLines()[1])
})
s.Run("Should error when creating a bot", func() {
printer.Clean()
botArg := "a-bot"
mockBot := model.Bot{Username: botArg, DisplayName: "", Description: ""}
s.client.
EXPECT().
CreateBot(context.TODO(), &mockBot).
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
err := botCreateCmdF(s.client, &cobra.Command{}, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "could not create bot")
})
}
func (s *MmctlUnitTestSuite) TestBotUpdateCmd() {
s.Run("Should update a bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("username", "new-username", "")
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
cmd.Flags().Lookup("username").Changed = true
cmd.Flags().Lookup("display-name").Changed = true
cmd.Flags().Lookup("description").Changed = true
mockBot := model.Bot{Username: "new-username", DisplayName: "some-name", Description: "some-text"}
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchBot(context.TODO(), mockUser.Id, gomock.Any()).
Return(&mockBot, &model.Response{}, nil).
Times(1)
err := botUpdateCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should error when user not found bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("username", "bot-username", "")
cmd.Flags().Lookup("username").Changed = true
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botUpdateCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "unable to find user 'a-bot'")
})
s.Run("Should error when updating bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
cmd.Flags().Lookup("display-name").Changed = true
cmd.Flags().Lookup("description").Changed = true
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchBot(context.TODO(), mockUser.Id, gomock.Any()).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botUpdateCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "could not update bot")
})
}
func (s *MmctlUnitTestSuite) TestBotListCmd() {
s.Run("Should list correctly all", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", true, "")
mockBot := model.Bot{UserId: model.NewId(), Username: botArg, DisplayName: "some-name", Description: "some-text", OwnerId: model.NewId()}
mockUser := model.User{Id: mockBot.OwnerId}
s.client.
EXPECT().
GetBotsIncludeDeleted(context.TODO(), 0, 200, "").
Return([]*model.Bot{&mockBot}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockBot.OwnerId}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should list fail if one featching all bots requests fail", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", true, "")
s.client.
EXPECT().
GetBotsIncludeDeleted(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "Failed to fetch bots")
})
s.Run("Should list correctly orphaned", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", true, "")
cmd.Flags().Bool("all", false, "")
mockBot := model.Bot{UserId: model.NewId(), Username: botArg, DisplayName: "some-name", Description: "some-text", OwnerId: model.NewId()}
mockUser := model.User{Id: mockBot.OwnerId}
s.client.
EXPECT().
GetBotsOrphaned(context.TODO(), 0, 200, "").
Return([]*model.Bot{&mockBot}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockBot.OwnerId}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should list fail if one featching bots orphaned requests fail", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", true, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetBotsOrphaned(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "Failed to fetch bots")
})
s.Run("Should list correctly bots", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", false, "")
mockBot := model.Bot{UserId: model.NewId(), Username: botArg, DisplayName: "some-name", Description: "some-text", OwnerId: model.NewId()}
mockUser := model.User{Id: mockBot.OwnerId}
s.client.
EXPECT().
GetBots(context.TODO(), 0, 200, "").
Return([]*model.Bot{&mockBot}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockBot.OwnerId}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should list correctly bots with invalid ownerId", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", false, "")
mockBot := model.Bot{UserId: model.NewId(), Username: botArg, DisplayName: "some-name", Description: "some-text", OwnerId: "Mr.Robot"}
s.client.
EXPECT().
GetBots(context.TODO(), 0, 200, "").
Return([]*model.Bot{&mockBot}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockBot.OwnerId}).
Return([]*model.User{}, &model.Response{}, nil).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should list fail if one fetching bots requests fail", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetBots(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "Failed to fetch bots")
})
s.Run("Should list fail if fetching owners requests fail", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().Bool("orphaned", false, "")
cmd.Flags().Bool("all", false, "")
mockBot := model.Bot{UserId: model.NewId(), Username: botArg, DisplayName: "some-name", Description: "some-text", OwnerId: model.NewId()}
s.client.
EXPECT().
GetBots(context.TODO(), 0, 200, "").
Return([]*model.Bot{&mockBot}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockBot.OwnerId}).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botListCmdF(s.client, cmd, []string{botArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "Failed to fetch bots")
})
}
func (s *MmctlUnitTestSuite) TestBotDisableCmd() {
s.Run("Should disable a bot", func() {
printer.Clean()
botArg := "a-bot"
mockBot := model.Bot{Username: botArg, DisplayName: "some-name", Description: "some-text"}
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DisableBot(context.TODO(), mockUser.Id).
Return(&mockBot, &model.Response{}, nil).
Times(1)
err := botDisableCmdF(s.client, &cobra.Command{}, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should error when user not found bot", func() {
printer.Clean()
botArg := "a-bot"
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botDisableCmdF(s.client, &cobra.Command{}, []string{botArg})
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "can't find user 'a-bot'")
})
s.Run("Should error when disabling bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
cmd.Flags().Lookup("display-name").Changed = true
cmd.Flags().Lookup("description").Changed = true
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DisableBot(context.TODO(), mockUser.Id).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botDisableCmdF(s.client, cmd, []string{botArg})
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "could not disable bot 'a-bot'")
})
}
func (s *MmctlUnitTestSuite) TestBotEnableCmd() {
s.Run("Should enable a bot", func() {
printer.Clean()
botArg := "a-bot"
mockBot := model.Bot{Username: botArg, DisplayName: "some-name", Description: "some-text"}
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
EnableBot(context.TODO(), mockUser.Id).
Return(&mockBot, &model.Response{}, nil).
Times(1)
err := botEnableCmdF(s.client, &cobra.Command{}, []string{botArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should error when user not found bot", func() {
printer.Clean()
botArg := "a-bot"
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botEnableCmdF(s.client, &cobra.Command{}, []string{botArg})
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "can't find user 'a-bot'")
})
s.Run("Should error when enabling bot", func() {
printer.Clean()
botArg := "a-bot"
cmd := &cobra.Command{}
cmd.Flags().String("display-name", "some-name", "")
cmd.Flags().String("description", "some-text", "")
cmd.Flags().Lookup("display-name").Changed = true
cmd.Flags().Lookup("description").Changed = true
mockUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
EnableBot(context.TODO(), mockUser.Id).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botEnableCmdF(s.client, cmd, []string{botArg})
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "could not enable bot 'a-bot'")
})
}
func (s *MmctlUnitTestSuite) TestBotAssignCmd() {
s.Run("Should assign a bot", func() {
printer.Clean()
botArg := "a-bot"
userArg := "a-user"
mockBot := model.Bot{Username: botArg, DisplayName: "some-name", Description: "some-text"}
mockBotUser := model.User{Id: model.NewId()}
mockNewOwner := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockBotUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), userArg, "").
Return(&mockNewOwner, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
AssignBot(context.TODO(), mockBotUser.Id, mockNewOwner.Id).
Return(&mockBot, &model.Response{}, nil).
Times(1)
err := botAssignCmdF(s.client, &cobra.Command{}, []string{botArg, userArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(&mockBot, printer.GetLines()[0])
})
s.Run("Should error when bot user not found", func() {
printer.Clean()
botArg := "a-bot"
userArg := "a-user"
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), botArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botAssignCmdF(s.client, &cobra.Command{}, []string{botArg, userArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "unable to find user 'a-bot'")
})
s.Run("Should error when new owner not found", func() {
printer.Clean()
botArg := "a-bot"
userArg := "a-user"
mockBotUser := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockBotUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), userArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), userArg, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botAssignCmdF(s.client, &cobra.Command{}, []string{botArg, userArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "unable to find user 'a-user'")
})
s.Run("Should error when assigning bot", func() {
printer.Clean()
botArg := "a-bot"
userArg := "a-user"
mockBotUser := model.User{Id: model.NewId()}
mockNewOwner := model.User{Id: model.NewId()}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), botArg, "").
Return(&mockBotUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), userArg, "").
Return(&mockNewOwner, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
AssignBot(context.TODO(), mockBotUser.Id, mockNewOwner.Id).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := botAssignCmdF(s.client, &cobra.Command{}, []string{botArg, userArg})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Contains(err.Error(), "can not assign bot 'a-bot' to user 'a-user'")
})
}

View File

@@ -0,0 +1,709 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var ChannelCmd = &cobra.Command{
Use: "channel",
Short: "Management of channels",
}
var ChannelCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a channel",
Long: `Create a channel.`,
Example: ` channel create --team myteam --name mynewchannel --display-name "My New Channel"
channel create --team myteam --name mynewprivatechannel --display-name "My New Private Channel" --private`,
RunE: withClient(createChannelCmdF),
}
// ChannelRenameCmd is used to change name and/or display name of an existing channel.
var ChannelRenameCmd = &cobra.Command{
Use: "rename [channel]",
Short: "Rename channel",
Long: `Rename an existing channel.`,
Example: ` channel rename myteam:oldchannel --name 'new-channel' --display-name 'New Display Name'
channel rename myteam:oldchannel --name 'new-channel'
channel rename myteam:oldchannel --display-name 'New Display Name'`,
Args: cobra.ExactArgs(1),
RunE: withClient(renameChannelCmdF),
}
var ArchiveChannelsCmd = &cobra.Command{
Use: "archive [channels]",
Short: "Archive channels",
Long: `Archive some channels.
Archive a channel along with all related information including posts from the database.
Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`,
Example: " channel archive myteam:mychannel",
RunE: withClient(archiveChannelsCmdF),
}
var DeleteChannelsCmd = &cobra.Command{
Use: "delete [channels]",
Short: "Delete channels",
Long: `Permanently delete some channels.
Permanently deletes one or multiple channels along with all related information including posts from the database.`,
Example: " channel delete myteam:mychannel",
Args: cobra.MinimumNArgs(1),
RunE: withClient(deleteChannelsCmdF),
}
// ListChannelsCmd is a command which lists all the channels of team(s) in a server.
var ListChannelsCmd = &cobra.Command{
Use: "list [teams]",
Short: "List all channels on specified teams.",
Long: `List all channels on specified teams.
Archived channels are appended with ' (archived)'.
Private channels the user is a member of or has access to are appended with ' (private)'.`,
Example: " channel list myteam",
Args: cobra.MinimumNArgs(1),
RunE: withClient(listChannelsCmdF),
}
var ModifyChannelCmd = &cobra.Command{
Use: "modify [channel] [flags]",
Short: "Modify a channel's public/private type",
Long: `Change the Public/Private type of a channel.
Channel can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`,
Example: ` channel modify myteam:mychannel --private
channel modify channelId --public`,
Args: cobra.ExactArgs(1),
RunE: withClient(modifyChannelCmdF),
}
var UnarchiveChannelCmd = &cobra.Command{
Use: "unarchive [channels]",
Short: "Unarchive some channels",
Long: `Unarchive a previously archived channel
Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`,
Example: " channel unarchive myteam:mychannel",
RunE: withClient(unarchiveChannelsCmdF),
}
var SearchChannelCmd = &cobra.Command{
Use: "search [channel]\n mmctl search --team [team] [channel]",
Short: "Search a channel",
Long: `Search a channel by channel name.
Channel can be specified by team. ie. --team myteam mychannel or by team ID.`,
Example: ` channel search mychannel
channel search --team myteam mychannel`,
Args: cobra.ExactArgs(1),
RunE: withClient(searchChannelCmdF),
}
var MoveChannelCmd = &cobra.Command{
Use: "move [team] [channels]",
Short: "Moves channels to the specified team",
Long: `Moves the provided channels to the specified team.
Validates that all users in the channel belong to the target team. If some users are not members of the target team, the move fails and the missing users are listed; use --auto-add-users to add them to the target team automatically, or --force to remove them from the channel. Incoming/Outgoing webhooks are moved along with the channel.
Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`,
Example: " channel move newteam oldteam:mychannel",
Args: cobra.MinimumNArgs(2),
RunE: withClient(moveChannelCmdF),
}
func init() {
ChannelCreateCmd.Flags().String("name", "", "Channel Name")
ChannelCreateCmd.Flags().String("display-name", "", "Channel Display Name")
ChannelCreateCmd.Flags().String("team", "", "Team name or ID")
ChannelCreateCmd.Flags().String("header", "", "Channel header")
ChannelCreateCmd.Flags().String("purpose", "", "Channel purpose")
ChannelCreateCmd.Flags().Bool("private", false, "Create a private channel.")
ModifyChannelCmd.Flags().Bool("private", false, "Convert the channel to a private channel")
ModifyChannelCmd.Flags().Bool("public", false, "Convert the channel to a public channel")
ChannelRenameCmd.Flags().String("name", "", "Channel Name")
ChannelRenameCmd.Flags().String("display-name", "", "Channel Display Name")
ListChannelsCmd.Flags().BoolP("show-ids", "i", false, "Show channel IDs")
SearchChannelCmd.Flags().String("team", "", "Team name or ID")
MoveChannelCmd.Flags().Bool("force", false, "Remove users that are not members of target team before moving the channel.")
MoveChannelCmd.Flags().Bool("auto-add-users", false, "Add users that are not members of the target team to it before moving the channel.")
DeleteChannelsCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the channel and a DB backup has been performed.")
ChannelCmd.AddCommand(
ChannelCreateCmd,
ArchiveChannelsCmd,
ListChannelsCmd,
UnarchiveChannelCmd,
ModifyChannelCmd,
ChannelRenameCmd,
SearchChannelCmd,
MoveChannelCmd,
DeleteChannelsCmd,
)
RootCmd.AddCommand(ChannelCmd)
}
func createChannelCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
name, errn := cmd.Flags().GetString("name")
if errn != nil || name == "" {
return errors.New("name is required")
}
displayname, errdn := cmd.Flags().GetString("display-name")
if errdn != nil || displayname == "" {
return errors.New("display-name is required")
}
teamArg, errteam := cmd.Flags().GetString("team")
if errteam != nil || teamArg == "" {
return errors.New("team is required")
}
header, _ := cmd.Flags().GetString("header")
purpose, _ := cmd.Flags().GetString("purpose")
useprivate, _ := cmd.Flags().GetBool("private")
channelType := model.ChannelTypeOpen
if useprivate {
channelType = model.ChannelTypePrivate
}
team := getTeamFromTeamArg(c, teamArg)
if team == nil {
return errors.Errorf("unable to find team: %s", teamArg)
}
channel := &model.Channel{
TeamId: team.Id,
Name: name,
DisplayName: displayname,
Header: header,
Purpose: purpose,
Type: channelType,
CreatorId: "",
}
newChannel, _, err := c.CreateChannel(context.TODO(), channel)
if err != nil {
return err
}
printer.PrintT("New channel {{.Name}} successfully created", newChannel)
return nil
}
func archiveChannelsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("enter at least one channel to archive")
}
channels := getChannelsFromChannelArgs(c, args)
var errors *multierror.Error
for i, channel := range channels {
if channel == nil {
printer.PrintError("Unable to find channel '" + args[i] + "'")
errors = multierror.Append(errors, fmt.Errorf("unable to find channel %q", args[i]))
continue
}
if _, err := c.DeleteChannel(context.TODO(), channel.Id); err != nil {
printer.PrintError("Unable to archive channel '" + channel.Name + "' error: " + err.Error())
errors = multierror.Append(errors, fmt.Errorf("unable to archive channel %q, error: %w", channel.Name, err))
}
}
return errors.ErrorOrNil()
}
func getAllPublicChannelsForTeam(c client.Client, teamID string) ([]*model.Channel, error) {
channels := []*model.Channel{}
page := 0
for {
channelsPage, _, err := c.GetPublicChannelsForTeam(context.TODO(), teamID, page, DefaultPageSize, "")
if err != nil {
return nil, err
}
if len(channelsPage) == 0 {
break
}
channels = append(channels, channelsPage...)
page++
}
return channels, nil
}
func getAllDeletedChannelsForTeam(c client.Client, teamID string) ([]*model.Channel, error) {
channels := []*model.Channel{}
page := 0
for {
channelsPage, _, err := c.GetDeletedChannelsForTeam(context.TODO(), teamID, page, DefaultPageSize, "")
if err != nil {
return nil, err
}
if len(channelsPage) == 0 {
break
}
channels = append(channels, channelsPage...)
page++
}
return channels, nil
}
func listChannelsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
teams := getTeamsFromTeamArgs(c, args)
showIds, _ := cmd.Flags().GetBool("show-ids")
namePrefix := "{{.Name}}"
if showIds {
namePrefix = "{{.Id}}: {{.Name}}"
}
var multierr *multierror.Error
for i, team := range teams {
if team == nil {
err := fmt.Errorf("unable to find team %q", args[i])
printer.PrintError(err.Error())
multierr = multierror.Append(multierr, err)
continue
}
publicChannels, err := getAllPublicChannelsForTeam(c, team.Id)
if err != nil {
printer.PrintError(fmt.Sprintf("unable to list public channels for %q: %s", args[i], err))
multierr = multierror.Append(multierr, err)
}
for _, channel := range publicChannels {
printer.PrintT(namePrefix, channel)
}
deletedChannels, err := getAllDeletedChannelsForTeam(c, team.Id)
if err != nil {
printer.PrintError(fmt.Sprintf("unable to list archived channels for %q: %s", args[i], err))
multierr = multierror.Append(multierr, err)
}
for _, channel := range deletedChannels {
printer.PrintT(namePrefix+" (archived)", channel)
}
privateChannels, appErr := getPrivateChannels(c, team.Id)
if appErr != nil {
printer.PrintError(fmt.Sprintf("unable to list private channels for %q: %s", args[i], appErr.Error()))
multierr = multierror.Append(multierr, appErr)
}
for _, channel := range privateChannels {
printer.PrintT(namePrefix+" (private)", channel)
}
}
return multierr.ErrorOrNil()
}
func unarchiveChannelsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("enter at least one channel")
}
var errs *multierror.Error
channels := getChannelsFromChannelArgs(c, args)
for i, channel := range channels {
if channel == nil {
msg := "Unable to find channel '" + args[i] + "'"
printer.PrintError(msg)
errs = multierror.Append(errs, errors.New(msg))
continue
}
if _, _, err := c.RestoreChannel(context.TODO(), channel.Id); err != nil {
msg := "Unable to unarchive channel '" + args[i] + "'. Error: " + err.Error()
printer.PrintError(msg)
errs = multierror.Append(errs, errors.New(msg))
}
}
return errs.ErrorOrNil()
}
func modifyChannelCmdF(c client.Client, cmd *cobra.Command, args []string) error {
public, _ := cmd.Flags().GetBool("public")
private, _ := cmd.Flags().GetBool("private")
if public == private {
return errors.New("you must specify only one of --public or --private")
}
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.Errorf("unable to find channel %q", args[0])
}
if !(channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypePrivate) {
return errors.New("you can only change the type of public/private channels")
}
privacy := model.ChannelTypeOpen
if private {
privacy = model.ChannelTypePrivate
}
if _, _, err := c.UpdateChannelPrivacy(context.TODO(), channel.Id, privacy); err != nil {
return errors.Errorf("failed to update channel (%q) privacy: %s", args[0], err.Error())
}
return nil
}
func renameChannelCmdF(c client.Client, cmd *cobra.Command, args []string) error {
existingTeamChannel := args[0]
newChannelName, err := cmd.Flags().GetString("name")
if err != nil {
return err
}
newDisplayName, err := cmd.Flags().GetString("display-name")
if err != nil {
return err
}
// At least one of display name or name flag must be present
if newDisplayName == "" && newChannelName == "" {
return errors.New("require at least one flag to rename channel, either 'name' or 'display-name'")
}
channel := getChannelFromChannelArg(c, existingTeamChannel)
if channel == nil {
return errors.Errorf("unable to find channel from %q", existingTeamChannel)
}
channelPatch := &model.ChannelPatch{}
if newChannelName != "" {
channelPatch.Name = &newChannelName
}
if newDisplayName != "" {
channelPatch.DisplayName = &newDisplayName
}
// Using PatchChannel API to rename channel
updatedChannel, _, err := c.PatchChannel(context.TODO(), channel.Id, channelPatch)
if err != nil {
return errors.Errorf("cannot rename channel %q, error: %s", channel.Name, err.Error())
}
printer.PrintT("'{{.Name}}' channel renamed", updatedChannel)
return nil
}
func searchChannelCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
var channel *model.Channel
if teamArg, _ := cmd.Flags().GetString("team"); teamArg != "" {
team := getTeamFromTeamArg(c, teamArg)
if team == nil {
return errors.Errorf("team %s was not found", teamArg)
}
var err error
channel, _, err = c.GetChannelByName(context.TODO(), args[0], team.Id, "")
if err != nil {
return err
}
if channel == nil {
return errors.Errorf("channel %s was not found in team %s", args[0], teamArg)
}
} else {
teams, err := getPages(func(page, numPerPage int, etag string) ([]*model.Team, *model.Response, error) {
return c.GetAllTeams(context.TODO(), etag, page, numPerPage)
}, DefaultPageSize)
if err != nil {
return err
}
for _, team := range teams {
channel, _, _ = c.GetChannelByName(context.TODO(), args[0], team.Id, "")
if channel != nil && channel.Name == args[0] {
break
}
}
if channel == nil {
return errors.Errorf("channel %q was not found in any team", args[0])
}
}
if channel.DeleteAt > 0 {
printer.PrintT("Channel Name :{{.Name}}, Display Name :{{.DisplayName}}, Channel ID :{{.Id}} (archived)", channel)
} else {
printer.PrintT("Channel Name :{{.Name}}, Display Name :{{.DisplayName}}, Channel ID :{{.Id}}", channel)
}
return nil
}
func moveChannelCmdF(c client.Client, cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
autoAddUsers, _ := cmd.Flags().GetBool("auto-add-users")
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return fmt.Errorf("unable to find destination team %q", args[0])
}
var result *multierror.Error
channels := getChannelsFromChannelArgs(c, args[1:])
for i, channel := range channels {
if channel == nil {
result = multierror.Append(result, fmt.Errorf("unable to find channel %q", args[i+1]))
continue
}
if channel.TeamId == team.Id {
continue
}
// When users are not being forcibly removed, the server rejects the move
// if any channel member is missing from the destination team. Surface
// those users to the operator, adding them to the team first if requested.
if !force {
missingUsers, err := getChannelMembersNotInTeam(c, channel.Id, team.Id)
if err != nil {
result = multierror.Append(result, fmt.Errorf("unable to determine missing team members for channel %q: %w", channel.Name, err))
continue
}
if len(missingUsers) > 0 {
if !autoAddUsers {
result = multierror.Append(result, fmt.Errorf("unable to move channel %q: the following users are not members of team %q: %s. Re-run with --auto-add-users to add them automatically, or with --force to remove them from the channel", channel.Name, team.Name, strings.Join(usernamesOf(missingUsers), ", ")))
continue
}
if err := addUsersToTeam(c, team, missingUsers); err != nil {
result = multierror.Append(result, err)
continue
}
}
}
newChannel, _, err := c.MoveChannel(context.TODO(), channel.Id, team.Id, force)
if err != nil {
result = multierror.Append(result, fmt.Errorf("unable to move channel %q: %w", channel.Name, err))
continue
}
printer.PrintT(fmt.Sprintf("Moved channel {{.Name}} to %q ({{.TeamId}}) from %s.", team.Name, channel.TeamId), newChannel)
}
return result.ErrorOrNil()
}
// getChannelMembersNotInTeam returns the users that are members of the channel
// but are not members of the given team.
func getChannelMembersNotInTeam(c client.Client, channelID, teamID string) ([]*model.User, error) {
channelMemberUserIDs, err := getAllChannelMemberUserIDs(c, channelID)
if err != nil {
return nil, fmt.Errorf("unable to get channel members: %w", err)
}
if len(channelMemberUserIDs) == 0 {
return []*model.User{}, nil
}
teamMembers, err := getTeamMembersByUserIDs(c, teamID, channelMemberUserIDs)
if err != nil {
return nil, fmt.Errorf("unable to get team members: %w", err)
}
teamMemberUserIDs := make(map[string]bool, len(teamMembers))
for _, member := range teamMembers {
teamMemberUserIDs[member.UserId] = true
}
missingUserIDs := []string{}
for _, userID := range channelMemberUserIDs {
if !teamMemberUserIDs[userID] {
missingUserIDs = append(missingUserIDs, userID)
}
}
if len(missingUserIDs) == 0 {
return []*model.User{}, nil
}
users, _, err := c.GetUsersByIds(context.TODO(), missingUserIDs)
if err != nil {
return nil, fmt.Errorf("unable to get users for missing channel members: %w", err)
}
usersByID := make(map[string]*model.User, len(users))
for _, user := range users {
usersByID[user.Id] = user
}
missingUsers := make([]*model.User, 0, len(missingUserIDs))
for _, userID := range missingUserIDs {
if user, ok := usersByID[userID]; ok {
missingUsers = append(missingUsers, user)
continue
}
missingUsers = append(missingUsers, &model.User{Id: userID, Username: userID})
}
return missingUsers, nil
}
func getAllChannelMemberUserIDs(c client.Client, channelID string) ([]string, error) {
userIDs := []string{}
page := 0
for {
membersPage, _, err := c.GetChannelMembers(context.TODO(), channelID, page, DefaultPageSize, "")
if err != nil {
return nil, err
}
if len(membersPage) == 0 {
break
}
for _, member := range membersPage {
userIDs = append(userIDs, member.UserId)
}
page++
}
return userIDs, nil
}
func getTeamMembersByUserIDs(c client.Client, teamID string, userIDs []string) ([]*model.TeamMember, error) {
teamMembers := []*model.TeamMember{}
for i := 0; i < len(userIDs); i += DefaultPageSize {
end := min(i+DefaultPageSize, len(userIDs))
members, _, err := c.GetTeamMembersByIds(context.TODO(), teamID, userIDs[i:end])
if err != nil {
return nil, err
}
teamMembers = append(teamMembers, members...)
}
return teamMembers, nil
}
func addUsersToTeam(c client.Client, team *model.Team, users []*model.User) error {
var result *multierror.Error
for _, user := range users {
if _, _, err := c.AddTeamMember(context.TODO(), team.Id, user.Id); err != nil {
result = multierror.Append(result, fmt.Errorf("unable to add user %q to team %q: %w", user.Username, team.Name, err))
continue
}
printer.PrintT(fmt.Sprintf("Added user {{.Username}} to team %q.", team.Name), user)
}
return result.ErrorOrNil()
}
func usernamesOf(users []*model.User) []string {
names := make([]string, len(users))
for i, user := range users {
names[i] = user.Username
}
return names
}
func getPrivateChannels(c client.Client, teamID string) ([]*model.Channel, error) {
allPrivateChannels := []*model.Channel{}
page := 0
withoutError := true
for {
channelsPage, _, err := c.GetPrivateChannelsForTeam(context.TODO(), teamID, page, DefaultPageSize, "")
if err != nil && viper.GetBool("local") {
return nil, err
} else if err != nil {
// This means that the user is not in local mode neither
// an admin, so we need to continue fetching the private
// channels specific to their credentials
withoutError = false
break
}
if len(channelsPage) == 0 {
break
}
allPrivateChannels = append(allPrivateChannels, channelsPage...)
page++
}
// if the break happened without an error, this means we're either
// in local mode or an admin, and we'll have all private channels
// by now, so we can safely return
if withoutError {
return allPrivateChannels, nil
}
// We are definitely not in local mode here so we can safely use
// "GetChannelsForTeamForUser" and "me" for userId
allChannels, response, err := c.GetChannelsForTeamForUser(context.TODO(), teamID, "me", false, "")
if err != nil {
if response.StatusCode == http.StatusNotFound { // user doesn't belong to any channels
return nil, nil
}
return nil, err
}
privateChannels := make([]*model.Channel, 0, len(allChannels))
for _, channel := range allChannels {
if channel.Type != model.ChannelTypePrivate {
continue
}
privateChannels = append(privateChannels, channel)
}
return privateChannels, nil
}
func deleteChannelsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
confirmFlag, _ := cmd.Flags().GetBool("confirm")
if !confirmFlag {
if err := getConfirmation("Are you sure you want to delete the channels specified? All data will be permanently deleted?", true); err != nil {
return err
}
}
var result *multierror.Error
channels := getChannelsFromChannelArgs(c, args)
for i, channel := range channels {
if channel == nil {
result = multierror.Append(result, fmt.Errorf("unable to find channel '%s'", args[i]))
continue
}
if _, err := c.PermanentDeleteChannel(context.TODO(), channel.Id); err != nil {
result = multierror.Append(result, fmt.Errorf("unable to delete channel '%q' error: %w", channel.Name, err))
} else {
printer.PrintT("Deleted channel '{{.Name}}'", channel)
}
}
return result.ErrorOrNil()
}

View File

@@ -0,0 +1,730 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestListChannelsCmdF() {
s.SetupTestHelper().InitBasic(s.T())
var assertChannelNames = func(want []string, lines []any) {
var got []string
for i := range lines {
got = append(got, lines[i].(*model.Channel).Name)
}
s.ElementsMatch(want, got)
}
s.Run("List channels/Client", func() {
printer.Clean()
wantNames := append(
s.th.App.DefaultChannelNames(s.th.Context),
[]string{
s.th.BasicChannel.Name,
s.th.BasicChannel2.Name,
s.th.BasicDeletedChannel.Name,
s.th.BasicPrivateChannel.Name,
}...,
)
err := listChannelsCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicTeam.Name})
s.Require().Nil(err)
s.Equal(6, len(printer.GetLines()))
assertChannelNames(wantNames, printer.GetLines())
s.Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("List channels", func(c client.Client) {
printer.Clean()
wantNames := append(
s.th.App.DefaultChannelNames(s.th.Context),
[]string{
s.th.BasicChannel.Name,
s.th.BasicChannel2.Name,
s.th.BasicDeletedChannel.Name,
s.th.BasicPrivateChannel.Name,
s.th.BasicPrivateChannel2.Name,
}...,
)
err := listChannelsCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name})
s.Require().Nil(err)
s.Equal(7, len(printer.GetLines()))
assertChannelNames(wantNames, printer.GetLines())
s.Len(printer.GetErrorLines(), 0)
})
s.RunForAllClients("List channels for non existent team", func(c client.Client) {
printer.Clean()
team := "non-existent-team"
err := listChannelsCmdF(c, &cobra.Command{}, []string{team})
s.Require().ErrorContains(err, "unable to find team \""+team+"\"")
s.Len(printer.GetErrorLines(), 1)
s.Equal("unable to find team \""+team+"\"", printer.GetErrorLines()[0])
})
}
func (s *MmctlE2ETestSuite) TestSearchChannelCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForAllClients("Search nonexistent channel", func(c client.Client) {
printer.Clean()
err := searchChannelCmdF(c, &cobra.Command{}, []string{"test"})
s.Require().NotNil(err)
s.Require().Equal(`channel "test" was not found in any team`, err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Search existing channel", func(c client.Client) {
printer.Clean()
err := searchChannelCmdF(c, &cobra.Command{}, []string{s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
actualChannel, ok := printer.GetLines()[0].(*model.Channel)
s.Require().True(ok)
s.Require().Equal(s.th.BasicChannel.Name, actualChannel.Name)
})
s.RunForSystemAdminAndLocal("Search existing channel of a team", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().String("team", s.th.BasicChannel.TeamId, "")
err := searchChannelCmdF(c, cmd, []string{s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
actualChannel, ok := printer.GetLines()[0].(*model.Channel)
s.Require().True(ok)
s.Require().Equal(s.th.BasicChannel.Name, actualChannel.Name)
})
s.RunForSystemAdminAndLocal("Search existing channel that does not belong to a team", func(c client.Client) {
printer.Clean()
testTeamName := api4.GenerateTestTeamName()
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: testTeamName,
DisplayName: "dn_" + testTeamName,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
cmd := &cobra.Command{}
cmd.Flags().String("team", team.Id, "")
err := searchChannelCmdF(c, cmd, []string{s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().ErrorContains(err, `Channel does not exist.`)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Search existing channel should fail for Client", func() {
printer.Clean()
err := searchChannelCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("channel \"%s\" was not found in any team", s.th.BasicChannel.Name), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestCreateChannelCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForAllClients("create channel successfully", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
channelName := model.NewRandomString(10)
teamName := s.th.BasicTeam.Name
channelDisplayName := "channelDisplayName"
cmd.Flags().String("name", channelName, "channel name")
cmd.Flags().String("team", teamName, "team name")
cmd.Flags().String("display-name", channelDisplayName, "display name")
err := createChannelCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
printerChannel := printer.GetLines()[0].(*model.Channel)
s.Require().Equal(channelName, printerChannel.Name)
s.Require().Equal(s.th.BasicTeam.Id, printerChannel.TeamId)
newChannel, err := s.th.App.GetChannelByName(s.th.Context, channelName, s.th.BasicTeam.Id, false)
s.Require().Nil(err)
s.Require().Equal(channelName, newChannel.Name)
s.Require().Equal(channelDisplayName, newChannel.DisplayName)
s.Require().Equal(s.th.BasicTeam.Id, newChannel.TeamId)
})
s.RunForAllClients("create channel with nonexistent team", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
channelName := model.NewRandomString(10)
teamName := "nonexistent team"
channelDisplayName := "channelDisplayName"
cmd.Flags().String("name", channelName, "channel name")
cmd.Flags().String("team", teamName, "team name")
cmd.Flags().String("display-name", channelDisplayName, "display name")
err := createChannelCmdF(c, cmd, []string{})
s.Require().NotNil(err)
s.Require().Equal("unable to find team: "+teamName, err.Error())
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
_, err = s.th.App.GetChannelByName(s.th.Context, channelName, s.th.BasicTeam.Id, false)
s.Require().NotNil(err)
})
s.RunForAllClients("create channel with invalid name", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
channelName := "invalid name"
teamName := s.th.BasicTeam.Name
channelDisplayName := "channelDisplayName"
cmd.Flags().String("name", channelName, "channel name")
cmd.Flags().String("team", teamName, "team name")
cmd.Flags().String("display-name", channelDisplayName, "display name")
err := createChannelCmdF(c, cmd, []string{})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "Name must be 1 or more lowercase alphanumeric character")
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
_, err = s.th.App.GetChannelByName(s.th.Context, channelName, s.th.BasicTeam.Id, false)
s.Require().NotNil(err)
})
}
func (s *MmctlE2ETestSuite) TestArchiveChannelsCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Archive channel", func() {
printer.Clean()
err := archiveChannelsCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicChannel.Name)})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Archive channel without permissions", func() {
printer.Clean()
err := archiveChannelsCmdF(s.th.LocalClient, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicChannel.Name)})
s.Require().Error(err)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to archive channel '%s'", s.th.BasicChannel.Name))
})
s.RunForAllClients("Archive nonexistent channel", func(c client.Client) {
printer.Clean()
err := archiveChannelsCmdF(c, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, "nonexistent-channel")})
s.Require().Error(err)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to find channel '%s:%s'", s.th.BasicTeam.Id, "nonexistent-channel"))
})
s.RunForSystemAdminAndLocal("Archive deleted channel", func(c client.Client) {
printer.Clean()
err := archiveChannelsCmdF(c, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicDeletedChannel.Name)})
s.Require().Error(err)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to archive channel '%s'", s.th.BasicDeletedChannel.Name))
s.Require().Contains(printer.GetErrorLines()[0], "The channel has been archived or deleted.")
})
}
func (s *MmctlE2ETestSuite) TestUnarchiveChannelsCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Unarchive channel", func() {
printer.Clean()
err := unarchiveChannelsCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicDeletedChannel.Name)})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
channel, appErr := s.th.App.GetChannel(s.th.Context, s.th.BasicDeletedChannel.Id)
s.Require().Nil(appErr)
s.Require().True(channel.IsOpen())
})
s.Run("Unarchive channel without permissions", func() {
printer.Clean()
err := unarchiveChannelsCmdF(s.th.Client, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicDeletedChannel.Name)})
expectedError := fmt.Sprintf("Unable to unarchive channel '%s:%s'", s.th.BasicTeam.Id, s.th.BasicDeletedChannel.Name)
s.Require().ErrorContains(err, expectedError)
s.Require().Contains(printer.GetErrorLines()[0], expectedError)
s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions.")
})
s.RunForAllClients("Unarchive nonexistent channel", func(c client.Client) {
printer.Clean()
err := unarchiveChannelsCmdF(c, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, "nonexistent-channel")})
expectedError := fmt.Sprintf("Unable to find channel '%s:%s'", s.th.BasicTeam.Id, "nonexistent-channel")
s.Require().ErrorContains(err, expectedError)
s.Require().Contains(printer.GetErrorLines()[0], expectedError)
})
s.Run("Unarchive open channel", func() {
printer.Clean()
err := unarchiveChannelsCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", s.th.BasicTeam.Id, s.th.BasicChannel.Name)})
expectedError := fmt.Sprintf("Unable to unarchive channel '%s:%s'", s.th.BasicTeam.Id, s.th.BasicChannel.Name)
s.Require().ErrorContains(err, expectedError)
s.Require().Contains(printer.GetErrorLines()[0], expectedError)
s.Require().Contains(printer.GetErrorLines()[0], "Unable to unarchive channel. The channel is not archived.")
})
}
func (s *MmctlE2ETestSuite) TestDeleteChannelsCmd() {
s.SetupTestHelper().InitBasic(s.T())
previousConfig := s.th.App.Config().ServiceSettings.EnableAPIChannelDeletion
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = true })
defer s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = *previousConfig })
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "Best Team",
Name: "best-team",
Type: model.TeamOpen,
Email: s.th.GenerateTestEmail(),
})
s.Require().Nil(appErr)
otherChannel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{Type: model.ChannelTypeOpen, Name: "channel_you_are_not_authorized_to", CreatorId: user.Id}, true)
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("Delete channel", func(c client.Client) {
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{Type: model.ChannelTypeOpen, Name: "channel_name", CreatorId: user.Id}, true)
s.Require().Nil(appErr)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
args := []string{team.Id + ":" + channel.Id}
printer.Clean()
err := deleteChannelsCmdF(c, cmd, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(channel, printer.GetLines()[0])
s.Require().Len(printer.GetErrorLines(), 0)
_, err = s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().NotNil(err)
s.CheckErrorID(err, "app.channel.get.existing.app_error")
})
s.Run("Delete channel with disabled config as system admin", func() {
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{Type: model.ChannelTypeOpen, Name: "channel_name_you_cannot_delete", CreatorId: user.Id}, true)
s.Require().Nil(appErr)
previousVal := s.th.App.Config().ServiceSettings.EnableAPIChannelDeletion
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = false })
defer func() {
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = *previousVal })
}()
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
args := []string{team.Id + ":" + channel.Id}
printer.Clean()
err := deleteChannelsCmdF(s.th.SystemAdminClient, cmd, args)
var expected error
expected = multierror.Append(expected, errors.New("unable to delete channel '\""+channel.Name+"\"' error: Permanent channel deletion feature is not enabled. ServiceSettings.EnableAPIChannelDeletion must be set to true to use this command. See https://mattermost.com/pl/environment-configuration-settings for more information"))
s.Require().NotNil(err)
s.Require().EqualError(err, expected.Error())
channel, err = s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(err)
s.Require().NotNil(channel)
})
s.Run("Delete channel with disabled config as local client", func() {
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{Type: model.ChannelTypeOpen, Name: "channel_name", CreatorId: user.Id}, true)
s.Require().Nil(appErr)
previousVal := s.th.App.Config().ServiceSettings.EnableAPIChannelDeletion
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = false })
defer func() {
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIChannelDeletion = *previousVal })
}()
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
args := []string{team.Id + ":" + channel.Id}
printer.Clean()
err := deleteChannelsCmdF(s.th.LocalClient, cmd, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(channel, printer.GetLines()[0])
s.Require().Len(printer.GetErrorLines(), 0)
// expect the channel deleted
_, err = s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().NotNil(err)
s.CheckErrorID(err, "app.channel.get.existing.app_error")
})
s.Run("Delete channel without permissions", func() {
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
args := []string{team.Id + ":" + otherChannel.Id}
printer.Clean()
err := deleteChannelsCmdF(s.th.Client, cmd, args)
arg := team.Id + ":" + otherChannel.Id
var expected error
expected = multierror.Append(expected, errors.New("unable to find channel '"+arg+"'"))
s.Require().NotNil(err)
s.Require().EqualError(err, expected.Error())
channel, err := s.th.App.GetChannel(s.th.Context, otherChannel.Id)
s.Require().Nil(err)
s.Require().NotNil(channel)
})
s.RunForAllClients("Delete not existing channel", func(c client.Client) {
notExistingChannelID := "not-existing-channel-ID"
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
args := []string{team.Id + ":" + notExistingChannelID}
printer.Clean()
err := deleteChannelsCmdF(c, cmd, args)
arg := team.Id + ":" + notExistingChannelID
var expected error
expected = multierror.Append(expected, errors.New("unable to find channel '"+arg+"'"))
s.Require().NotNil(err)
s.Require().EqualError(err, expected.Error())
channel, err := s.th.App.GetChannel(s.th.Context, notExistingChannelID)
s.Require().Nil(channel)
s.Require().NotNil(err)
s.CheckErrorID(err, "app.channel.get.existing.app_error")
})
}
func (s *MmctlE2ETestSuite) TestChannelRenameCmd() {
s.SetupTestHelper().InitBasic(s.T())
initChannelName := api4.GenerateTestChannelName()
initChannelDisplayName := "dn_" + initChannelName
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: initChannelName,
DisplayName: initChannelDisplayName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
s.RunForAllClients("Rename nonexistent channel", func(c client.Client) {
printer.Clean()
nonexistentChannelName := api4.GenerateTestChannelName()
cmd := &cobra.Command{}
cmd.Flags().String("name", "name", "")
cmd.Flags().String("display-name", "name", "")
err := renameChannelCmdF(c, cmd, []string{s.th.BasicTeam.Id + ":" + nonexistentChannelName})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel from \"%s:%s\"", s.th.BasicTeam.Id, nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Rename channel", func(c client.Client) {
printer.Clean()
newChannelName := api4.GenerateTestChannelName()
newChannelDisplayName := "dn_" + newChannelName
cmd := &cobra.Command{}
cmd.Flags().String("name", newChannelName, "")
cmd.Flags().String("display-name", newChannelDisplayName, "")
err := renameChannelCmdF(c, cmd, []string{s.th.BasicTeam.Id + ":" + channel.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
printedChannel, ok := printer.GetLines()[0].(*model.Channel)
s.Require().True(ok, "unexpected printer output type")
s.Require().Equal(newChannelName, printedChannel.Name)
s.Require().Equal(newChannelDisplayName, printedChannel.DisplayName)
rchannel, err := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(err)
s.Require().Equal(newChannelName, rchannel.Name)
s.Require().Equal(newChannelDisplayName, rchannel.DisplayName)
})
s.Run("Rename channel without permission", func() {
printer.Clean()
channelInit, appErr := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(appErr)
newChannelName := api4.GenerateTestChannelName()
newChannelDisplayName := "dn_" + newChannelName
cmd := &cobra.Command{}
cmd.Flags().String("name", newChannelName, "")
cmd.Flags().String("display-name", newChannelDisplayName, "")
err := renameChannelCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Id + ":" + channel.Id})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(fmt.Sprintf("cannot rename channel \"%s\", error: You do not have the appropriate permissions.", channelInit.Name), err.Error())
rchannel, err := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(err)
s.Require().Equal(channelInit.Name, rchannel.Name)
s.Require().Equal(channelInit.DisplayName, rchannel.DisplayName)
})
s.Run("Rename channel with permission", func() {
printer.Clean()
_, appErr := s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
newChannelName := api4.GenerateTestChannelName()
newChannelDisplayName := "dn_" + newChannelName
cmd := &cobra.Command{}
cmd.Flags().String("name", newChannelName, "")
cmd.Flags().String("display-name", newChannelDisplayName, "")
err := renameChannelCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Id + ":" + channel.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
printedChannel, ok := printer.GetLines()[0].(*model.Channel)
s.Require().True(ok, "unexpected printer output type")
s.Require().Equal(newChannelName, printedChannel.Name)
s.Require().Equal(newChannelDisplayName, printedChannel.DisplayName)
rchannel, err := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(err)
s.Require().Equal(newChannelName, rchannel.Name)
s.Require().Equal(newChannelDisplayName, rchannel.DisplayName)
})
}
func (s *MmctlE2ETestSuite) TestMoveChannelCmd() {
s.SetupTestHelper().InitBasic(s.T())
initChannelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: initChannelName,
DisplayName: "dName_" + initChannelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
s.RunForAllClients("Move nonexistent team", func(c client.Client) {
printer.Clean()
err := moveChannelCmdF(c, &cobra.Command{}, []string{"test"})
s.Require().Error(err)
s.Require().Equal(`unable to find destination team "test"`, err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Move existing channel to specified team", func(c client.Client) {
printer.Clean()
testTeamName := api4.GenerateTestTeamName()
var team *model.Team
team, appErr = s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: testTeamName,
DisplayName: "dName_" + testTeamName,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
args := []string{team.Id, channel.Id}
cmd := &cobra.Command{}
err := moveChannelCmdF(c, cmd, args)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
actualChannel, ok := printer.GetLines()[0].(*model.Channel)
s.Require().True(ok)
s.Require().Equal(channel.Name, actualChannel.Name)
s.Require().Equal(team.Id, actualChannel.TeamId)
})
s.RunForSystemAdminAndLocal("Moving team to non existing channel", func(c client.Client) {
printer.Clean()
args := []string{s.th.BasicTeam.Id, "no-channel"}
cmd := &cobra.Command{}
var expected error
expected = multierror.Append(expected, fmt.Errorf("unable to find channel %q", "no-channel"))
err := moveChannelCmdF(c, cmd, args)
s.Require().EqualError(err, expected.Error())
})
s.RunForSystemAdminAndLocal("Moving channel which is already moved to particular team", func(c client.Client) {
printer.Clean()
initChannelName := api4.GenerateTestChannelName()
channel, appErr = s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: initChannelName,
DisplayName: "dName_" + initChannelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
args := []string{channel.TeamId, channel.Id}
cmd := &cobra.Command{}
err := moveChannelCmdF(c, cmd, args)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Move existing channel to specified team should fail for client", func() {
printer.Clean()
testTeamName := api4.GenerateTestTeamName()
var team *model.Team
team, appErr = s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: testTeamName,
DisplayName: "dName_" + testTeamName,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
args := []string{team.Id, channel.Id}
cmd := &cobra.Command{}
err := moveChannelCmdF(s.th.Client, cmd, args)
s.Require().Error(err)
s.Require().Equal(fmt.Sprintf("unable to find destination team %q", team.Id), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Move channel auto-adding users missing from the destination team", func(c client.Client) {
printer.Clean()
testTeamName := api4.GenerateTestTeamName()
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: testTeamName,
DisplayName: "dName_" + testTeamName,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
srcChannelName := api4.GenerateTestChannelName()
srcChannel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: srcChannelName,
DisplayName: "dName_" + srcChannelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
// A channel member that belongs to the source team but not the destination team.
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, s.th.BasicTeam.Id, user.Id, "")
s.Require().Nil(appErr)
_, appErr = s.th.App.AddUserToChannel(s.th.Context, user, srcChannel, false)
s.Require().Nil(appErr)
// Without the flag the move is rejected and the missing user is reported.
err := moveChannelCmdF(c, &cobra.Command{}, []string{team.Id, srcChannel.Id})
s.Require().Error(err)
s.Require().Contains(err.Error(), user.Username)
s.Require().Contains(err.Error(), "--auto-add-users")
_, appErr = s.th.App.GetTeamMember(s.th.Context, team.Id, user.Id)
s.Require().NotNil(appErr)
// With the flag the user is added to the destination team and the move succeeds.
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("auto-add-users", true, "")
err = moveChannelCmdF(c, cmd, []string{team.Id, srcChannel.Id})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
movedChannel, appErr := s.th.App.GetChannel(s.th.Context, srcChannel.Id)
s.Require().Nil(appErr)
s.Require().Equal(team.Id, movedChannel.TeamId)
teamMember, appErr := s.th.App.GetTeamMember(s.th.Context, team.Id, user.Id)
s.Require().Nil(appErr)
s.Require().Equal(user.Id, teamMember.UserId)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var ChannelUsersCmd = &cobra.Command{
Use: "users",
Short: "Management of channel users",
}
var ChannelUsersAddCmd = &cobra.Command{
Use: "add [channel] [users]",
Short: "Add users to channel",
Long: "Add some users to channel",
Example: " channel users add myteam:mychannel user@example.com username",
RunE: withClient(channelUsersAddCmdF),
}
var ChannelUsersRemoveCmd = &cobra.Command{
Use: "remove [channel] [users]",
Short: "Remove users from channel",
Long: "Remove some users from channel",
Example: ` channel users remove myteam:mychannel user@example.com username
channel users remove myteam:mychannel --all-users`,
RunE: withClient(channelUsersRemoveCmdF),
}
var ChannelUsersListCmd = &cobra.Command{
Use: "list [channel]",
Short: "List users of a channel",
Long: "List the users belonging to a channel, printing each member's id, username, email and roles",
Example: ` channel users list myteam:mychannel
channel users list myteam:mychannel --all`,
Args: cobra.ExactArgs(1),
RunE: withClient(channelUsersListCmdF),
}
func init() {
ChannelUsersRemoveCmd.Flags().Bool("all-users", false, "Remove all users from the indicated channel.")
ChannelUsersListCmd.Flags().Int("page", 0, "Page number to fetch for the list of channel users")
ChannelUsersListCmd.Flags().Int("per-page", DefaultPageSize, "Number of channel users to be fetched")
ChannelUsersListCmd.Flags().Bool("all", false, "Fetch all channel users. --page flag will be ignored if provided")
ChannelUsersCmd.AddCommand(
ChannelUsersAddCmd,
ChannelUsersRemoveCmd,
ChannelUsersListCmd,
)
ChannelCmd.AddCommand(ChannelUsersCmd)
}
func channelUsersAddCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return errors.New("not enough arguments")
}
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.Errorf("unable to find channel %q", args[0])
}
var result *multierror.Error
users := getUsersFromUserArgs(c, args[1:])
for i, user := range users {
err := addUserToChannel(c, channel, user, args[i+1])
if err != nil {
printer.PrintError(err.Error())
result = multierror.Append(result, err)
}
}
return result.ErrorOrNil()
}
func addUserToChannel(c client.Client, channel *model.Channel, user *model.User, userArg string) error {
if user == nil {
return fmt.Errorf("unable to find user %q", userArg)
}
if _, _, err := c.AddChannelMember(context.TODO(), channel.Id, user.Id); err != nil {
return fmt.Errorf("unable to add %q to %q. Error: %w", userArg, channel.Name, err)
}
return nil
}
func channelUsersRemoveCmdF(c client.Client, cmd *cobra.Command, args []string) error {
allUsers, _ := cmd.Flags().GetBool("all-users")
if allUsers && len(args) != 1 {
return errors.New("individual users must not be specified in conjunction with the --all-users flag")
}
if !allUsers && len(args) < 2 {
return errors.New("you must specify some users to remove from the channel, or use the --all-users flag to remove them all")
}
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.Errorf("unable to find channel %q", args[0])
}
var result *multierror.Error
if allUsers {
if err := removeAllUsersFromChannel(c, channel); err != nil {
return err
}
} else {
for i, user := range getUsersFromUserArgs(c, args[1:]) {
err := removeUserFromChannel(c, channel, user, args[i+1])
if err != nil {
printer.PrintError(err.Error())
result = multierror.Append(result, err)
}
}
}
return result.ErrorOrNil()
}
func removeUserFromChannel(c client.Client, channel *model.Channel, user *model.User, userArg string) error {
if user == nil {
return fmt.Errorf("unable to find user %q", userArg)
}
if _, err := c.RemoveUserFromChannel(context.TODO(), channel.Id, user.Id); err != nil {
return fmt.Errorf("unable to remove %q from %q. Error: %w", userArg, channel.Name, err)
}
return nil
}
func removeAllUsersFromChannel(c client.Client, channel *model.Channel) error {
var result *multierror.Error
members, _, err := c.GetChannelMembers(context.TODO(), channel.Id, 0, 10000, "")
if err != nil {
printer.PrintError("Unable to remove all users from " + channel.Name + ". Error: " + err.Error())
return fmt.Errorf("unable to remove all users from %q: %w", channel.Name, err)
}
for _, member := range members {
if _, err := c.RemoveUserFromChannel(context.TODO(), channel.Id, member.UserId); err != nil {
result = multierror.Append(result, fmt.Errorf("unable to remove %q from %q Error: %w", member.UserId, channel.Name, err))
printer.PrintError("Unable to remove '" + member.UserId + "' from " + channel.Name + ". Error: " + err.Error())
}
}
return result.ErrorOrNil()
}
// channelUserOut is the output format for a channel member, combining the
// channel membership roles with the user's identifying details.
type channelUserOut struct {
Id string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Roles string `json:"roles"`
}
func channelUsersListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.Errorf("unable to find channel %q", args[0])
}
all, _ := cmd.Flags().GetBool("all")
perPage, _ := cmd.Flags().GetInt("per-page")
var members model.ChannelMembers
if all {
var err error
members, err = getPages(func(page, perPage int, etag string) ([]model.ChannelMember, *model.Response, error) {
return c.GetChannelMembers(context.TODO(), channel.Id, page, perPage, etag)
}, perPage)
if err != nil {
return errors.Errorf("unable to list users of channel %q: %s", channel.Name, err.Error())
}
} else {
page, _ := cmd.Flags().GetInt("page")
var err error
members, _, err = c.GetChannelMembers(context.TODO(), channel.Id, page, perPage, "")
if err != nil {
return errors.Errorf("unable to list users of channel %q: %s", channel.Name, err.Error())
}
}
if len(members) == 0 {
printer.Print("No users found")
return nil
}
userIDs := make([]string, len(members))
for i := range members {
userIDs[i] = members[i].UserId
}
users, _, err := c.GetUsersByIds(context.TODO(), userIDs)
if err != nil {
return errors.Errorf("unable to retrieve user details for channel %q: %s", channel.Name, err.Error())
}
usersByID := make(map[string]*model.User, len(users))
for _, user := range users {
usersByID[user.Id] = user
}
for _, member := range members {
out := channelUserOut{
Id: member.UserId,
Roles: member.Roles,
}
if user, ok := usersByID[member.UserId]; ok {
out.Username = user.Username
out.Email = user.Email
}
printer.PrintT("{{.Id}}: {{.Username}} ({{.Email}}) {{.Roles}}", out)
}
return nil
}

View File

@@ -0,0 +1,376 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestChannelUsersAddCmdF() {
s.SetupTestHelper().InitBasic(s.T())
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, s.th.BasicTeam.Id, user.Id, "")
s.Require().Nil(appErr)
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("Add user to nonexistent channel", func(c client.Client) {
printer.Clean()
nonexistentChannelName := "nonexistent"
err := channelUsersAddCmdF(c, &cobra.Command{}, []string{nonexistentChannelName, user.Id})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel %q", nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Add user to nonexistent channel/Client", func() {
printer.Clean()
_, appErr := s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr := s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
nonexistentChannelName := "nonexistent"
err := channelUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{nonexistentChannelName, user.Id})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel %q", nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Add nonexistent user to channel", func(c client.Client) {
printer.Clean()
nonexistentUserName := "nonexistent"
err := channelUsersAddCmdF(c, &cobra.Command{}, []string{channel.Id, nonexistentUserName})
s.Require().ErrorContains(err, "unable to find user")
s.Require().ErrorContains(err, nonexistentUserName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
})
s.Run("Add nonexistent user to channel/Client", func() {
printer.Clean()
_, appErr := s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr := s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
nonexistentUserName := "nonexistent"
err := channelUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, nonexistentUserName})
s.Require().ErrorContains(err, "unable to find user")
s.Require().ErrorContains(err, nonexistentUserName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
})
s.Run("Add user to channel without permission/Client", func() {
printer.Clean()
err := channelUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().ErrorContains(err, "unable to add")
s.Require().ErrorContains(err, user.Id)
s.Require().ErrorContains(err, channelName)
s.Require().ErrorContains(err, "You do not have the appropriate permissions")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(fmt.Sprintf("unable to add %q to %q. Error: You do not have the appropriate permissions.", user.Id, channelName), printer.GetErrorLines()[0])
})
s.Run("Add user to channel/Client", func() {
printer.Clean()
_, appErr := s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
err := channelUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().Nil(err)
defer func() {
appErr = s.th.App.RemoveUserFromChannel(s.th.Context, user.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
members, appErr := s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 1)
s.Require().Equal(user.Id, (members)[0].UserId)
})
s.RunForSystemAdminAndLocal("Add user to channel", func(c client.Client) {
printer.Clean()
err := channelUsersAddCmdF(c, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().Nil(err)
defer func() {
appErr := s.th.App.RemoveUserFromChannel(s.th.Context, user.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
members, appErr := s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 1)
s.Require().Equal(user.Id, (members)[0].UserId)
})
}
func (s *MmctlE2ETestSuite) TestChannelUsersListCmd() {
s.SetupTestHelper().InitBasic(s.T())
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, s.th.BasicTeam.Id, user.Id, "")
s.Require().Nil(appErr)
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
_, appErr = s.th.App.AddChannelMember(s.th.Context, user.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("List users of a channel", func(c client.Client) {
printer.Clean()
err := channelUsersListCmdF(c, channelUsersListTestCmd(), []string{channel.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().NotEmpty(printer.GetLines())
var found bool
for _, line := range printer.GetLines() {
out, ok := line.(channelUserOut)
s.Require().True(ok)
if out.Id == user.Id {
found = true
s.Require().Equal(user.Username, out.Username)
s.Require().Equal(user.Email, out.Email)
s.Require().NotEmpty(out.Roles)
}
}
s.Require().True(found, "expected the added user to be listed as a channel member")
})
s.RunForSystemAdminAndLocal("List all users of a channel with the --all flag", func(c client.Client) {
printer.Clean()
cmd := channelUsersListTestCmd()
err := cmd.Flags().Set("all", "true")
s.Require().NoError(err)
err = channelUsersListCmdF(c, cmd, []string{channel.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
var found bool
for _, line := range printer.GetLines() {
out, ok := line.(channelUserOut)
s.Require().True(ok)
if out.Id == user.Id {
found = true
s.Require().Equal(user.Username, out.Username)
s.Require().Equal(user.Email, out.Email)
s.Require().NotEmpty(out.Roles)
}
}
s.Require().True(found, "expected the added user to be listed as a channel member")
})
s.RunForSystemAdminAndLocal("List users of a nonexistent channel", func(c client.Client) {
printer.Clean()
nonexistentChannelName := "nonexistent"
err := channelUsersListCmdF(c, channelUsersListTestCmd(), []string{nonexistentChannelName})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel %q", nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestChannelUsersRemoveCmd() {
s.SetupTestHelper().InitBasic(s.T())
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, s.th.BasicTeam.Id, user.Id, "")
s.Require().Nil(appErr)
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
s.RunForSystemAdminAndLocal("Remove user from nonexistent channel", func(c client.Client) {
printer.Clean()
nonexistentChannelName := "nonexistent"
err := channelUsersRemoveCmdF(c, &cobra.Command{}, []string{nonexistentChannelName, user.Id})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel %q", nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Remove user from nonexistent channel/Client", func() {
printer.Clean()
_, appErr = s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
nonexistentChannelName := "nonexistent"
err := channelUsersRemoveCmdF(s.th.Client, &cobra.Command{}, []string{nonexistentChannelName, user.Id})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("unable to find channel %q", nonexistentChannelName), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Remove nonexistent user from channel", func(c client.Client) {
printer.Clean()
nonexistentUserName := "nonexistent"
err := channelUsersRemoveCmdF(c, &cobra.Command{}, []string{channel.Id, nonexistentUserName})
s.Require().ErrorContains(err, "unable to find user")
s.Require().ErrorContains(err, nonexistentUserName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(fmt.Sprintf("unable to find user %q", nonexistentUserName), printer.GetErrorLines()[0])
})
s.Run("Remove nonexistent user from channel/Client", func() {
printer.Clean()
_, appErr = s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
nonexistentUserName := "nonexistent"
err := channelUsersRemoveCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, nonexistentUserName})
s.Require().ErrorContains(err, "unable to find user")
s.Require().ErrorContains(err, nonexistentUserName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(fmt.Sprintf("unable to find user %q", nonexistentUserName), printer.GetErrorLines()[0])
})
s.Run("Remove user from channel without permission/Client", func() {
printer.Clean()
var members model.ChannelMembers
_, appErr = s.th.App.AddChannelMember(s.th.Context, user.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
members, appErr = s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 1)
s.Require().Equal(user.Id, (members)[0].UserId)
err := channelUsersRemoveCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().ErrorContains(err, "unable to remove")
s.Require().ErrorContains(err, user.Id)
s.Require().ErrorContains(err, channelName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("unable to remove %q from %q", user.Id, channelName))
s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions")
})
s.Run("Remove user from channel/Client", func() {
printer.Clean()
_, appErr = s.th.App.AddChannelMember(s.th.Context, s.th.BasicUser.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.RemoveUserFromChannel(s.th.Context, s.th.BasicUser.Id, s.th.SystemAdminUser.Id, channel)
s.Require().Nil(appErr)
}()
var members model.ChannelMembers
_, appErr = s.th.App.AddChannelMember(s.th.Context, user.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
members, appErr = s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 1)
s.Require().Equal(user.Id, (members)[0].UserId)
err := channelUsersRemoveCmdF(s.th.Client, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
members, appErr = s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 0)
})
s.RunForSystemAdminAndLocal("Remove user from channel", func(c client.Client) {
printer.Clean()
_, appErr = s.th.App.AddChannelMember(s.th.Context, user.Id, channel, app.ChannelMemberOpts{})
s.Require().Nil(appErr)
members, appErr := s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 1)
s.Require().Equal(user.Id, (members)[0].UserId)
err := channelUsersRemoveCmdF(c, &cobra.Command{}, []string{channel.Id, user.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
members, appErr = s.th.App.GetChannelMembersByIds(s.th.Context, channel.Id, []string{user.Id})
s.Require().Nil(appErr)
s.Require().Len(members, 0)
})
}

View File

@@ -0,0 +1,826 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestChannelUsersAddCmdF() {
channelArg := teamID + ":" + channelName
mockTeam := model.Team{Id: teamID}
mockChannel := model.Channel{Id: channelID, Name: channelName}
mockUser := model.User{Id: userID, Email: userEmail}
s.Run("Not enough command line parameters", func() {
printer.Clean()
cmd := &cobra.Command{}
// One argument provided.
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg})
s.EqualError(err, "not enough arguments")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
// No arguments provided.
err = channelUsersAddCmdF(s.client, cmd, []string{})
s.EqualError(err, "not enough arguments")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Add existing user to existing channel", func() {
printer.Clean()
cmd := &cobra.Command{}
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByEmail(context.TODO(), userEmail, "").
Return(&mockUser, &model.Response{}, nil).
Times(3)
s.client.
EXPECT().
AddChannelMember(context.TODO(), channelID, userID).
Return(&model.ChannelMember{}, &model.Response{}, nil).
Times(2)
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, userEmail})
s.Require().Nil(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Add existing user to nonexistent channel", func() {
printer.Clean()
cmd := &cobra.Command{}
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
// No channel is returned by client.
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(nil, &model.Response{}, nil).
Times(1)
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, userEmail})
s.EqualError(err, fmt.Sprintf("unable to find channel %q", channelArg))
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Add existing user to channel owned by nonexistent team", func() {
printer.Clean()
cmd := &cobra.Command{}
// No team is returned by client.
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetTeamByName(context.TODO(), teamID, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, userEmail})
s.EqualError(err, fmt.Sprintf("unable to find channel %q", channelArg))
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Add multiple users, some nonexistent to existing channel", func() {
printer.Clean()
nilUserArg := "nonexistent-user"
cmd := &cobra.Command{}
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), nilUserArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), nilUserArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, nilUserArg, userEmail})
s.Require().ErrorContains(err, "unable to find user")
s.Require().ErrorContains(err, nilUserArg)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 1)
})
s.Run("Error adding existing user to existing channel", func() {
printer.Clean()
cmd := &cobra.Command{}
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
AddChannelMember(context.TODO(), channelID, userID).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, userEmail})
s.Require().ErrorContains(err, "unable to add")
s.Require().ErrorContains(err, userEmail)
s.Require().ErrorContains(err, channelName)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 1)
})
}
func channelUsersListTestCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", DefaultPageSize, "")
cmd.Flags().Bool("all", false, "")
return cmd
}
func (s *MmctlUnitTestSuite) TestChannelUsersListCmd() {
channelArg := teamID + ":" + channelName
mockTeam := model.Team{Id: teamID}
mockChannel := model.Channel{Id: channelID, Name: channelName}
mockUser := model.User{Id: userID, Username: "user1", Email: userEmail}
mockUser2 := model.User{Id: userID + "2", Username: "user2", Email: userID + "2@example.com"}
mockUser3 := model.User{Id: userID + "3", Username: "user3", Email: userID + "3@example.com"}
member1 := model.ChannelMember{ChannelId: channelID, UserId: mockUser.Id, Roles: "channel_user"}
member2 := model.ChannelMember{ChannelId: channelID, UserId: mockUser2.Id, Roles: "channel_user channel_admin"}
member3 := model.ChannelMember{ChannelId: channelID, UserId: mockUser3.Id, Roles: "channel_user"}
s.Run("List users of a channel", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{member1, member2}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser.Id, mockUser2.Id}).
Return([]*model.User{&mockUser, &mockUser2}, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Len(printer.GetErrorLines(), 0)
out1 := printer.GetLines()[0].(channelUserOut)
s.Require().Equal(mockUser.Id, out1.Id)
s.Require().Equal(mockUser.Username, out1.Username)
s.Require().Equal(mockUser.Email, out1.Email)
s.Require().Equal("channel_user", out1.Roles)
out2 := printer.GetLines()[1].(channelUserOut)
s.Require().Equal(mockUser2.Id, out2.Id)
s.Require().Equal(mockUser2.Username, out2.Username)
s.Require().Equal(mockUser2.Email, out2.Email)
s.Require().Equal("channel_user channel_admin", out2.Roles)
})
s.Run("List all users of a channel with the --all flag", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
err := cmd.Flags().Set("all", "true")
s.Require().NoError(err)
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
// The --all flag must keep paging until an empty page is returned,
// accumulating members across every non-empty page.
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{member1, member2}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 1, DefaultPageSize, "").
Return(model.ChannelMembers{member3}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 2, DefaultPageSize, "").
Return(model.ChannelMembers{}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser.Id, mockUser2.Id, mockUser3.Id}).
Return([]*model.User{&mockUser, &mockUser2, &mockUser3}, &model.Response{}, nil).
Times(1)
err = channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 3)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(mockUser.Id, printer.GetLines()[0].(channelUserOut).Id)
s.Require().Equal(mockUser2.Id, printer.GetLines()[1].(channelUserOut).Id)
s.Require().Equal(mockUser3.Id, printer.GetLines()[2].(channelUserOut).Id)
})
s.Run("List users with custom --page and --per-page", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.Require().NoError(cmd.Flags().Set("page", "2"))
s.Require().NoError(cmd.Flags().Set("per-page", "50"))
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
// The --page and --per-page flags must be threaded through to the API call.
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 2, 50, "").
Return(model.ChannelMembers{member1}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser.Id}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(mockUser.Id, printer.GetLines()[0].(channelUserOut).Id)
})
s.Run("List users renders id, username, email and roles in plain output", func() {
printer.Clean()
printer.SetFormat(printer.FormatPlain)
defer printer.SetFormat(printer.FormatJSON)
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{member2}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser2.Id}).
Return([]*model.User{&mockUser2}, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
expected := fmt.Sprintf("%s: %s (%s) %s", mockUser2.Id, mockUser2.Username, mockUser2.Email, member2.Roles)
s.Require().Equal(expected, printer.GetLines()[0])
})
s.Run("List users when a member's user details are missing", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{member1, member2}, &model.Response{}, nil).
Times(1)
// GetUsersByIds omits member2's user (e.g. deactivated/deleted).
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser.Id, mockUser2.Id}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
out1 := printer.GetLines()[0].(channelUserOut)
s.Require().Equal(mockUser.Id, out1.Id)
s.Require().Equal(mockUser.Username, out1.Username)
s.Require().Equal(mockUser.Email, out1.Email)
// The row for the missing user still lists its id and roles, with empty username/email.
out2 := printer.GetLines()[1].(channelUserOut)
s.Require().Equal(mockUser2.Id, out2.Id)
s.Require().Equal("channel_user channel_admin", out2.Roles)
s.Require().Empty(out2.Username)
s.Require().Empty(out2.Email)
})
s.Run("List all users when fetching members fails", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.Require().NoError(cmd.Flags().Set("all", "true"))
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().ErrorContains(err, "unable to list users")
s.Require().ErrorContains(err, channelName)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("List users of a nonexistent channel", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetTeamByName(context.TODO(), teamID, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().EqualError(err, fmt.Sprintf("unable to find channel %q", channelArg))
s.Require().Len(printer.GetLines(), 0)
})
s.Run("List users of an empty channel", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{}, &model.Response{}, nil).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("No users found", printer.GetLines()[0])
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("List users when fetching members fails", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().ErrorContains(err, "unable to list users")
s.Require().ErrorContains(err, channelName)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("List users when fetching user details fails", func() {
printer.Clean()
cmd := channelUsersListTestCmd()
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(&mockTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, teamID, "").
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), channelID, 0, DefaultPageSize, "").
Return(model.ChannelMembers{member1}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.TODO(), []string{mockUser.Id}).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := channelUsersListCmdF(s.client, cmd, []string{channelArg})
s.Require().ErrorContains(err, "unable to retrieve user details")
s.Require().ErrorContains(err, channelName)
s.Require().Len(printer.GetLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestChannelUsersRemoveCmd() {
mockUser := model.User{Id: userID, Email: userEmail}
mockUser2 := model.User{Id: userID + "2", Email: userID + "2@example.com"}
mockUser3 := model.User{Id: userID + "3", Email: userID + "3@example.com"}
argsTeamChannel := teamName + ":" + channelName
s.Run("should remove user from channel", func() {
printer.Clean()
cmd := &cobra.Command{}
args := []string{argsTeamChannel, userEmail}
foundTeam := &model.Team{
Id: teamID,
DisplayName: teamDisplayName,
Name: teamName,
}
foundChannel := &model.Channel{
Id: channelID,
Name: channelName,
DisplayName: channelDisplayName,
}
s.client.
EXPECT().
GetTeam(context.TODO(), teamName, "").
Return(foundTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, foundTeam.Id, "").
Return(foundChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByEmail(context.TODO(), userEmail, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("should throw error if both --all-users flag and user email are passed", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("all-users", true, "Remove all users from the indicated channel.")
args := []string{argsTeamChannel, userEmail}
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().EqualError(err, "individual users must not be specified in conjunction with the --all-users flag")
})
s.Run("should remove all users from channel", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("all-users", true, "Remove all users from the indicated channel.")
args := []string{argsTeamChannel}
foundTeam := &model.Team{
Id: teamID,
DisplayName: teamDisplayName,
Name: teamName,
}
foundChannel := &model.Channel{
Id: channelID,
Name: channelName,
DisplayName: channelDisplayName,
}
mockMember1 := model.ChannelMember{ChannelId: channelID, UserId: mockUser.Id}
mockMember2 := model.ChannelMember{ChannelId: channelID, UserId: mockUser2.Id}
mockMember3 := model.ChannelMember{ChannelId: channelID, UserId: mockUser3.Id}
mockChannelMembers := model.ChannelMembers{mockMember1, mockMember2, mockMember3}
s.client.
EXPECT().
GetTeam(context.TODO(), teamName, "").
Return(foundTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByEmail(context.TODO(), userEmail, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, foundTeam.Id, "").
Return(foundChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), foundChannel.Id, 0, 10000, "").
Return(mockChannelMembers, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser2.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser3.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("should remove multiple users from channel", func() {
printer.Clean()
cmd := &cobra.Command{}
args := []string{argsTeamChannel, userEmail, mockUser2.Email}
foundTeam := &model.Team{
Id: teamID,
DisplayName: teamDisplayName,
Name: teamName,
}
foundChannel := &model.Channel{
Id: channelID,
Name: channelName,
DisplayName: channelDisplayName,
}
s.client.
EXPECT().
GetTeam(context.TODO(), teamName, "").
Return(foundTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, foundTeam.Id, "").
Return(foundChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByEmail(context.TODO(), userEmail, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), mockUser2.Email, "").
Return(&mockUser2, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser2.Id).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("should remove all users from channel throws error", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("all-users", true, "Remove all users from the indicated channel.")
args := []string{argsTeamChannel}
foundTeam := &model.Team{
Id: teamID,
DisplayName: teamDisplayName,
Name: teamName,
}
foundChannel := &model.Channel{
Id: channelID,
Name: channelName,
DisplayName: channelDisplayName,
}
mockMember1 := model.ChannelMember{ChannelId: channelID, UserId: mockUser.Id}
mockChannelMembers := model.ChannelMembers{mockMember1}
s.client.
EXPECT().
GetTeam(context.TODO(), teamName, "").
Return(foundTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, foundTeam.Id, "").
Return(foundChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelMembers(context.TODO(), foundChannel.Id, 0, 10000, "").
Return(mockChannelMembers, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser.Id).
Return(&model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")).
Times(1)
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().ErrorContains(err, "unable to remove")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
})
s.Run("should remove user from channel throws error", func() {
printer.Clean()
cmd := &cobra.Command{}
args := []string{argsTeamChannel, userEmail}
foundTeam := &model.Team{
Id: teamID,
DisplayName: teamDisplayName,
Name: teamName,
}
foundChannel := &model.Channel{
Id: channelID,
Name: channelName,
DisplayName: channelDisplayName,
}
s.client.
EXPECT().
GetTeam(context.TODO(), teamName, "").
Return(foundTeam, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelName, foundTeam.Id, "").
Return(foundChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
RemoveUserFromChannel(context.TODO(), foundChannel.Id, mockUser.Id).
Return(&model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")).
Times(1)
err := channelUsersRemoveCmdF(s.client, cmd, args)
s.Require().ErrorContains(err, "unable to remove")
s.Require().ErrorContains(err, userEmail)
s.Require().ErrorContains(err, channelName)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
})
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
)
const channelArgSeparator = ":"
func getChannelsFromChannelArgs(c client.Client, channelArgs []string) []*model.Channel {
channels := make([]*model.Channel, 0, len(channelArgs))
for _, channelArg := range channelArgs {
channel := getChannelFromChannelArg(c, channelArg)
channels = append(channels, channel)
}
return channels
}
func parseChannelArg(channelArg string) (string, string) {
result := strings.SplitN(channelArg, channelArgSeparator, 2)
if len(result) == 1 {
return "", channelArg
}
return result[0], result[1]
}
func getChannelFromChannelArg(c client.Client, channelArg string) *model.Channel {
teamArg, channelPart := parseChannelArg(channelArg)
if teamArg == "" && channelPart == "" {
return nil
}
if checkDots(channelPart) || checkSlash(channelPart) {
return nil
}
var channel *model.Channel
if teamArg != "" {
team := getTeamFromTeamArg(c, teamArg)
if team == nil {
return nil
}
channel, _, _ = c.GetChannelByNameIncludeDeleted(context.TODO(), channelPart, team.Id, "")
}
if channel == nil {
channel, _, _ = c.GetChannel(context.TODO(), channelPart)
}
return channel
}
// getChannelsFromArgs obtains channels by the `channelArgs` parameter. It can return channels and errors
// at the same time
//
//nolint:golint,unused
func getChannelsFromArgs(c client.Client, channelArgs []string) ([]*model.Channel, error) {
var channels []*model.Channel
var result *multierror.Error
for _, channelArg := range channelArgs {
channel, err := getChannelFromArg(c, channelArg)
if err != nil {
result = multierror.Append(result, err)
continue
}
channels = append(channels, channel)
}
return channels, result.ErrorOrNil()
}
//nolint:golint,unused
func getChannelFromArg(c client.Client, arg string) (*model.Channel, error) {
teamArg, channelArg := parseChannelArg(arg)
if teamArg == "" && channelArg == "" {
return nil, fmt.Errorf("invalid channel argument %q", arg)
}
if checkDots(channelArg) || checkSlash(channelArg) {
return nil, fmt.Errorf(`invalid channel argument. Cannot contain ".." nor "/"`)
}
var channel *model.Channel
var response *model.Response
if teamArg != "" {
team, err := getTeamFromArg(c, teamArg)
if err != nil {
return nil, err
}
channel, response, err = c.GetChannelByNameIncludeDeleted(context.TODO(), channelArg, team.Id, "")
if err != nil {
err = ExtractErrorFromResponse(response, err)
var nfErr *NotFoundError
var badRequestErr *BadRequestError
if !errors.As(err, &nfErr) && !errors.As(err, &badRequestErr) {
return nil, err
}
}
}
if channel != nil {
return channel, nil
}
var err error
channel, response, err = c.GetChannel(context.TODO(), channelArg)
if err != nil {
nErr := ExtractErrorFromResponse(response, err)
var nfErr *NotFoundError
var badRequestErr *BadRequestError
if !errors.As(nErr, &nfErr) && !errors.As(nErr, &badRequestErr) {
return nil, nErr
}
}
if channel == nil {
return nil, ErrEntityNotFound{Type: "channel", ID: arg}
}
return channel, nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (s *MmctlUnitTestSuite) TestGetChannelArgs() {
s.Run("channel not found", func() {
notFoundChannel := "notfoundchannel"
notFoundErr := errors.New("channel not found")
s.client.
EXPECT().
GetChannel(context.TODO(), notFoundChannel).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr).
Times(1)
channels, err := getChannelsFromArgs(s.client, []string{notFoundChannel})
s.Require().Empty(channels)
s.Require().NotNil(err)
s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* channel %s not found\n\n", notFoundChannel))
})
s.Run("bad request", func() {
badRequestChannel := "badrequest"
badRequestErr := errors.New("channel bad request")
s.client.
EXPECT().
GetChannel(context.TODO(), badRequestChannel).
Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr).
Times(1)
channels, err := getChannelsFromArgs(s.client, []string{badRequestChannel})
s.Require().Empty(channels)
s.Require().NotNil(err)
s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* channel %s not found\n\n", badRequestChannel))
})
s.Run("forbidden", func() {
forbidden := "forbidden"
forbiddenErr := errors.New("channel forbidden")
s.client.
EXPECT().
GetChannel(context.TODO(), forbidden).
Return(nil, &model.Response{StatusCode: http.StatusForbidden}, forbiddenErr).
Times(1)
channels, err := getChannelsFromArgs(s.client, []string{forbidden})
s.Require().Empty(channels)
s.Require().NotNil(err)
s.Require().EqualError(err, "1 error occurred:\n\t* channel forbidden\n\n")
})
s.Run("internal server error", func() {
errChannel := "internalServerError"
internalServerErrorErr := errors.New("channel internalServerError")
s.client.
EXPECT().
GetChannel(context.TODO(), errChannel).
Return(nil, &model.Response{StatusCode: http.StatusInternalServerError}, internalServerErrorErr).
Times(1)
channels, err := getChannelsFromArgs(s.client, []string{errChannel})
s.Require().Empty(channels)
s.Require().NotNil(err)
s.Require().EqualError(err, "1 error occurred:\n\t* channel internalServerError\n\n")
})
s.Run("success", func() {
successID := "success"
successChannel := &model.Channel{Id: successID}
s.client.
EXPECT().
GetChannel(context.TODO(), successID).
Return(successChannel, nil, nil).
Times(1)
channels, summary := getChannelsFromArgs(s.client, []string{successID})
s.Require().Nil(summary)
s.Require().Len(channels, 1)
s.Require().Equal(successChannel, channels[0])
})
s.Run("success with team on channel", func() {
channelID := "success"
teamID := "myTeamID"
successTeam := &model.Team{Id: teamID}
successChannel := &model.Channel{Id: channelID}
s.client.
EXPECT().
GetTeam(context.TODO(), teamID, "").
Return(successTeam, nil, nil).
Times(1)
s.client.
EXPECT().
GetChannelByNameIncludeDeleted(context.TODO(), channelID, teamID, "").
Return(successChannel, nil, nil).
Times(1)
channels, summary := getChannelsFromArgs(s.client, []string{fmt.Sprintf("%v:%v", teamID, channelID)})
s.Require().Nil(summary)
s.Require().Len(channels, 1)
s.Require().Equal(successChannel, channels[0])
})
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
var CommandCmd = &cobra.Command{
Use: "command",
Short: "Management of slash commands",
}
var CommandCreateCmd = &cobra.Command{
Use: "create [team]",
Short: "Create a custom slash command",
Long: `Create a custom slash command for the specified team.`,
Args: cobra.MinimumNArgs(1),
Example: ` command create myteam --title MyCommand --description "My Command Description" --trigger-word mycommand --url http://localhost:8000/my-slash-handler --creator myusername --response-username my-bot-username --icon http://localhost:8000/my-slash-handler-bot-icon.png --autocomplete --post`,
RunE: withClient(createCommandCmdF),
}
var CommandListCmd = &cobra.Command{
Use: "list [teams]",
Short: "List all commands on specified teams.",
Long: `List all commands on specified teams.`,
Example: ` command list myteam`,
RunE: withClient(listCommandCmdF),
}
var CommandArchiveCmd = &cobra.Command{
Use: "archive [commandID]",
Short: "Archive a slash command",
Long: `Archive a slash command. Commands can be specified by command ID.`,
Example: ` command archive commandID`,
Args: cobra.ExactArgs(1),
RunE: withClient(archiveCommandCmdF),
}
var CommandModifyCmd = &cobra.Command{
Use: "modify [commandID]",
Short: "Modify a slash command",
Long: `Modify a slash command. Commands can be specified by command ID.`,
Args: cobra.ExactArgs(1),
Example: ` command modify commandID --title MyModifiedCommand --description "My Modified Command Description" --trigger-word mycommand --url http://localhost:8000/my-slash-handler --creator myusername --response-username my-bot-username --icon http://localhost:8000/my-slash-handler-bot-icon.png --autocomplete --post`,
RunE: withClient(modifyCommandCmdF),
}
var CommandMoveCmd = &cobra.Command{
Use: "move [team] [commandID]",
Short: "Move a slash command to a different team",
Long: `Move a slash command to a different team. Commands can be specified by command ID.`,
Args: cobra.ExactArgs(2),
Example: ` command move newteam commandID`,
RunE: withClient(moveCommandCmdF),
}
var CommandShowCmd = &cobra.Command{
Use: "show [commandID]",
Short: "Show a custom slash command",
Long: `Show a custom slash command. Commands can be specified by command ID. Returns command ID, team ID, trigger word, display name and creator username.`,
Args: cobra.ExactArgs(1),
Example: ` command show commandID`,
RunE: withClient(showCommandCmdF),
}
func addCommandFieldsFlags(cmd *cobra.Command) {
cmd.Flags().String("title", "", "Command Title")
cmd.Flags().String("description", "", "Command Description")
cmd.Flags().String("trigger-word", "", "Command Trigger Word (required)")
cmd.Flags().String("url", "", "Command Callback URL (required)")
cmd.Flags().String("creator", "", "Command Creator's username, email or id (required)")
cmd.Flags().String("response-username", "", "Command Response Username")
cmd.Flags().String("icon", "", "Command Icon URL")
cmd.Flags().Bool("autocomplete", false, "Show Command in autocomplete list")
cmd.Flags().String("autocompleteDesc", "", "Short Command Description for autocomplete list")
cmd.Flags().String("autocompleteHint", "", "Command Arguments displayed as help in autocomplete list")
cmd.Flags().Bool("post", false, "Use POST method for Callback URL")
}
func init() {
cmds := []*cobra.Command{CommandCreateCmd, CommandModifyCmd}
for _, cmd := range cmds {
addCommandFieldsFlags(cmd)
}
_ = CommandCreateCmd.MarkFlagRequired("trigger-word")
_ = CommandCreateCmd.MarkFlagRequired("url")
_ = CommandCreateCmd.MarkFlagRequired("creator")
CommandCmd.AddCommand(
CommandCreateCmd,
CommandListCmd,
CommandModifyCmd,
CommandMoveCmd,
CommandShowCmd,
CommandArchiveCmd,
)
RootCmd.AddCommand(CommandCmd)
}
func createCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return errors.New("unable to find team '" + args[0] + "'")
}
// get the creator
creator, _ := cmd.Flags().GetString("creator")
user := getUserFromUserArg(c, creator)
if user == nil {
return errors.New("unable to find user '" + creator + "'")
}
title, _ := cmd.Flags().GetString("title")
description, _ := cmd.Flags().GetString("description")
trigger, _ := cmd.Flags().GetString("trigger-word")
if strings.HasPrefix(trigger, "/") {
return errors.New("a trigger word cannot begin with a /")
}
if strings.Contains(trigger, " ") {
return errors.New("a trigger word must not contain spaces")
}
url, _ := cmd.Flags().GetString("url")
responseUsername, _ := cmd.Flags().GetString("response-username")
icon, _ := cmd.Flags().GetString("icon")
autocomplete, _ := cmd.Flags().GetBool("autocomplete")
autocompleteDesc, _ := cmd.Flags().GetString("autocompleteDesc")
autocompleteHint, _ := cmd.Flags().GetString("autocompleteHint")
post, errp := cmd.Flags().GetBool("post")
method := "P"
if errp != nil || !post {
method = "G"
}
newCommand := &model.Command{
CreatorId: user.Id,
TeamId: team.Id,
Trigger: trigger,
Method: method,
Username: responseUsername,
IconURL: icon,
AutoComplete: autocomplete,
AutoCompleteDesc: autocompleteDesc,
AutoCompleteHint: autocompleteHint,
DisplayName: title,
Description: description,
URL: url,
}
createdCommand, _, err := c.CreateCommand(context.TODO(), newCommand)
if err != nil {
return errors.New("unable to create command '" + newCommand.DisplayName + "'. " + err.Error())
}
printer.PrintT("created command {{.DisplayName}}", createdCommand)
return nil
}
func listCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
var teams []*model.Team
if len(args) < 1 {
teamList, err := getPages(func(page, numPerPage int, etag string) ([]*model.Team, *model.Response, error) {
return c.GetAllTeams(context.TODO(), etag, page, numPerPage)
}, DefaultPageSize)
if err != nil {
return err
}
teams = teamList
} else {
teams = getTeamsFromTeamArgs(c, args)
}
var errs *multierror.Error
for i, team := range teams {
if team == nil {
printer.PrintError("Unable to find team '" + args[i] + "'")
errs = multierror.Append(errs, fmt.Errorf("unable to find team '%s'", args[i]))
continue
}
commands, _, err := c.ListCommands(context.TODO(), team.Id, true)
if err != nil {
printer.PrintError("Unable to list commands for '" + team.Id + "'")
errs = multierror.Append(errs, fmt.Errorf("unable to list commands for '%s': %w", team.Id, err))
continue
}
for _, command := range commands {
printer.PrintT("{{.Id}}: {{.DisplayName}} (team: "+team.Name+")", command)
}
}
return errs.ErrorOrNil()
}
func archiveCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
resp, err := c.DeleteCommand(context.TODO(), args[0])
if err != nil {
return errors.New("Unable to archive command '" + args[0] + "' error: " + err.Error())
}
if resp.StatusCode == http.StatusOK {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "ok"})
} else {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "error"})
}
return nil
}
func modifyCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
command := getCommandFromCommandArg(c, args[0])
if command == nil {
return fmt.Errorf("unable to find command '%s'", args[0])
}
flags := cmd.Flags()
if flags.Changed("title") {
command.DisplayName, _ = flags.GetString("title")
}
if flags.Changed("description") {
command.Description, _ = flags.GetString("description")
}
if flags.Changed("trigger-word") {
trigger, _ := flags.GetString("trigger-word")
if strings.HasPrefix(trigger, "/") {
return errors.New("a trigger word cannot begin with a /")
}
if strings.Contains(trigger, " ") {
return errors.New("a trigger word must not contain spaces")
}
command.Trigger = trigger
}
if flags.Changed("url") {
command.URL, _ = flags.GetString("url")
}
if flags.Changed("creator") {
creator, _ := flags.GetString("creator")
user := getUserFromUserArg(c, creator)
if user == nil {
return fmt.Errorf("unable to find user '%s'", creator)
}
command.CreatorId = user.Id
}
if flags.Changed("response-username") {
command.Username, _ = flags.GetString("response-username")
}
if flags.Changed("icon") {
command.IconURL, _ = flags.GetString("icon")
}
if flags.Changed("autocomplete") {
command.AutoComplete, _ = flags.GetBool("autocomplete")
}
if flags.Changed("autocompleteDesc") {
command.AutoCompleteDesc, _ = flags.GetString("autocompleteDesc")
}
if flags.Changed("autocompleteHint") {
command.AutoCompleteHint, _ = flags.GetString("autocompleteHint")
}
if flags.Changed("post") {
post, _ := flags.GetBool("post")
if post {
command.Method = "P"
} else {
command.Method = "G"
}
}
modifiedCommand, _, err := c.UpdateCommand(context.TODO(), command)
if err != nil {
return fmt.Errorf("unable to modify command '%s': %w", command.DisplayName, err)
}
printer.PrintT("modified command {{.DisplayName}}", modifiedCommand)
return nil
}
func moveCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
newTeam := getTeamFromTeamArg(c, args[0])
if newTeam == nil {
return fmt.Errorf("unable to find team '%s'", args[0])
}
command := getCommandFromCommandArg(c, args[1])
if command == nil {
return fmt.Errorf("unable to find command '%s'", args[1])
}
resp, err := c.MoveCommand(context.TODO(), newTeam.Id, command.Id)
if err != nil {
return fmt.Errorf("unable to move command '%s': %w", command.Id, err)
}
if resp.StatusCode == http.StatusOK {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "ok"})
} else {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "error"})
}
return nil
}
func showCommandCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
command := getCommandFromCommandArg(c, args[0])
if command == nil {
return fmt.Errorf("unable to find command '%s'", args[0])
}
template :=
`teamId: {{.TeamId}}
title: {{.DisplayName}}
description: {{.Description}}
trigger-word: {{.Trigger}}
URL: {{.URL}}
creatorId: {{.CreatorId}}
response-username: {{.Username}}
iconURL: {{.IconURL}}
autoComplete: {{.AutoComplete}}
autoCompleteDesc: {{.AutoCompleteDesc}}
autoCompleteHint: {{.AutoCompleteHint}}
method: {{.Method}}`
printer.PrintT(template, command)
return nil
}

View File

@@ -0,0 +1,378 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestListCommandCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForAllClients("List commands for a non existing team", func(c client.Client) {
printer.Clean()
nonexistentTeamID := "nonexistent-team-id"
err := listCommandCmdF(c, &cobra.Command{}, []string{nonexistentTeamID})
s.Require().Error(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 1)
s.Equal("Unable to find team '"+nonexistentTeamID+"'", printer.GetErrorLines()[0])
})
s.RunForAllClients("List commands for a specific team", func(c client.Client) {
printer.Clean()
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.BasicUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, team.Id, s.th.BasicUser.Id, "")
s.Require().Nil(appErr)
command, appErr := s.th.App.CreateCommand(&model.Command{
DisplayName: "command",
CreatorId: s.th.BasicUser.Id,
TeamId: team.Id,
URL: "http://localhost:8000/example",
Method: model.CommandMethodGet,
Trigger: "trigger",
})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.DeleteCommand(command.Id)
s.Require().Nil(appErr)
}()
err := listCommandCmdF(c, &cobra.Command{}, []string{team.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Equal(command, printer.GetLines()[0])
s.Len(printer.GetErrorLines(), 0)
})
s.Run("List all commands from all teams", func() {
// add team1
team1, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.BasicUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, team1.Id, s.th.BasicUser.Id, "")
s.Require().Nil(appErr)
command1, appErr := s.th.App.CreateCommand(&model.Command{
DisplayName: "command1",
CreatorId: s.th.BasicUser.Id,
TeamId: team1.Id,
URL: "http://localhost:8000/example",
Method: model.CommandMethodGet,
Trigger: "trigger",
})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.DeleteCommand(command1.Id)
s.Require().Nil(appErr)
}()
// add team 2
team2, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.BasicUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, team2.Id, s.th.BasicUser.Id, "")
s.Require().Nil(appErr)
command2, appErr := s.th.App.CreateCommand(&model.Command{
DisplayName: "command2",
CreatorId: s.th.BasicUser.Id,
TeamId: team2.Id,
URL: "http://localhost:8000/example",
Method: model.CommandMethodGet,
Trigger: "trigger",
})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.DeleteCommand(command2.Id)
s.Require().Nil(appErr)
}()
s.RunForSystemAdminAndLocal("Run list command", func(c client.Client) {
printer.Clean()
err := listCommandCmdF(c, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Len(printer.GetLines(), 2)
s.ElementsMatch([]*model.Command{command1, command2}, printer.GetLines())
s.Len(printer.GetErrorLines(), 0)
})
})
s.Run("List commands for a specific team without permission", func() {
printer.Clean()
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.BasicUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
command, appErr := s.th.App.CreateCommand(&model.Command{
DisplayName: "command",
CreatorId: s.th.BasicUser.Id,
TeamId: team.Id,
URL: "http://localhost:8000/example",
Method: model.CommandMethodGet,
Trigger: "trigger",
})
s.Require().Nil(appErr)
defer func() {
appErr = s.th.App.DeleteCommand(command.Id)
s.Require().Nil(appErr)
}()
err := listCommandCmdF(s.th.Client, &cobra.Command{}, []string{team.Id})
s.Require().Error(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 1)
s.Equal("Unable to find team '"+team.Id+"'", printer.GetErrorLines()[0])
})
}
func (s *MmctlE2ETestSuite) TestArchiveCommandCmdF() {
s.SetupTestHelper().InitBasic(s.T())
teamOfBasicUser, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.BasicUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, teamOfBasicUser.Id, s.th.BasicUser.Id, "")
s.Require().Nil(appErr)
s.RunForAllClients("Archive nonexistent command", func(c client.Client) {
printer.Clean()
nonexistentCommandID := "nonexistent-command-id"
err := archiveCommandCmdF(c, &cobra.Command{}, []string{nonexistentCommandID})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("Unable to archive command '%s' error: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/commands/nonexistent-command-id'. Typo? are you missing a team_id or user_id as part of the url?", nonexistentCommandID), err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForAllClients("Archive command", func(c client.Client) {
printer.Clean()
command, appErr := s.th.App.CreateCommand(&model.Command{
TeamId: teamOfBasicUser.Id,
DisplayName: "command",
Description: "command",
Trigger: api4.GenerateTestID(),
URL: "http://localhost:8000/example",
CreatorId: s.th.BasicUser.Id,
Username: s.th.BasicUser.Username,
IconURL: "http://localhost:8000/icon.ico",
Method: model.CommandMethodGet,
})
s.Require().Nil(appErr)
err := archiveCommandCmdF(c, &cobra.Command{}, []string{command.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(map[string]any{"status": "ok"}, printer.GetLines()[0])
s.Require().Len(printer.GetErrorLines(), 0)
rcommand, err := s.th.App.GetCommand(command.Id)
s.Require().NotNil(err)
s.Require().Nil(rcommand)
s.Require().Contains(err.Error(), "SqlCommandStore.Get: Command does not exist., ")
})
s.Run("Archive command without permission", func() {
printer.Clean()
teamOfAdminUser, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
DisplayName: "dn_" + model.NewId(),
Name: api4.GenerateTestTeamName(),
Email: s.th.SystemAdminUser.Email,
Type: model.TeamOpen,
})
s.Require().Nil(appErr)
command, appErr := s.th.App.CreateCommand(&model.Command{
TeamId: teamOfAdminUser.Id,
DisplayName: "command",
Description: "command",
Trigger: api4.GenerateTestID(),
URL: "http://localhost:8000/example",
CreatorId: s.th.SystemAdminUser.Id,
Username: s.th.SystemAdminUser.Username,
IconURL: "http://localhost:8000/icon.ico",
Method: model.CommandMethodGet,
})
s.Require().Nil(appErr)
err := archiveCommandCmdF(s.th.Client, &cobra.Command{}, []string{command.Id})
s.Require().NotNil(err)
s.Require().Equal(fmt.Sprintf("Unable to archive command '%s' error: Unable to get the command.", command.Id), err.Error())
rcommand, err := s.th.App.GetCommand(command.Id)
s.Require().Nil(err)
s.Require().NotNil(rcommand)
s.Require().Equal(int64(0), rcommand.DeleteAt)
})
}
func (s *MmctlE2ETestSuite) TestModifyCommandCmdF() {
s.SetupTestHelper().InitBasic(s.T())
// create new command
newCmd := &model.Command{
CreatorId: s.th.BasicUser.Id,
TeamId: s.th.BasicTeam.Id,
URL: "http://nowhere.com",
Method: model.CommandMethodPost,
Trigger: "trigger",
}
command, _, _ := s.th.SystemAdminClient.CreateCommand(context.Background(), newCmd)
index := 0
s.RunForSystemAdminAndLocal("modifyCommandCmdF", func(c client.Client) {
printer.Clean()
// Reset the cmd and parse to force Flag.Changed to be true.
cmd := CommandModifyCmd
cmd.ResetFlags()
addCommandFieldsFlags(cmd)
url := fmt.Sprintf("%s-%d", command.URL, index)
index++
err := cmd.ParseFlags([]string{
command.Id,
"--url=" + url,
})
s.Require().Nil(err)
err = modifyCommandCmdF(c, cmd, []string{command.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
changedCommand, err := s.th.App.GetCommand(command.Id)
s.Require().Nil(err)
s.Require().Equal(url, changedCommand.URL)
})
s.RunForSystemAdminAndLocal("modifyCommandCmdF for command that does not exist", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
err := modifyCommandCmdF(c, cmd, []string{"nothing"})
s.Require().NotNil(err)
s.Require().Equal("unable to find command 'nothing'", err.Error())
})
s.RunForSystemAdminAndLocal("modifyCommandCmdF with a space in trigger word", func(c client.Client) {
printer.Clean()
// Reset the cmd and parse to force Flag.Changed to be true.
cmd := CommandModifyCmd
cmd.ResetFlags()
addCommandFieldsFlags(cmd)
err := cmd.ParseFlags([]string{
command.Id,
"--trigger-word=modified with space",
})
s.Require().Nil(err)
err = modifyCommandCmdF(c, cmd, []string{command.Id})
s.Require().NotNil(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
s.EqualError(err, "a trigger word must not contain spaces")
})
}
func (s *MmctlE2ETestSuite) TestShowCommandCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Show non existent cmd", func(c client.Client) {
printer.Clean()
err := showCommandCmdF(c, &cobra.Command{}, []string{"nonexistent-command-id"})
s.Require().Error(err)
s.Require().Equal("unable to find command 'nonexistent-command-id'", err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Show commands with cmd id", func(c client.Client) {
// create new command
printer.Clean()
newCmd := &model.Command{
CreatorId: s.th.BasicUser.Id,
TeamId: s.th.BasicTeam.Id,
URL: "http://nowhere.com",
Method: model.CommandMethodPost,
Trigger: model.NewRandomString(6),
}
command, _, err := c.CreateCommand(context.Background(), newCmd)
s.Require().NoError(err)
err = showCommandCmdF(c, &cobra.Command{}, []string{command.Id})
s.Require().NoError(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal(command, printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("Show commands with team:trigger", func(c client.Client) {
// create new command
printer.Clean()
trigger := model.NewRandomString(6)
newCmd := &model.Command{
CreatorId: s.th.BasicUser.Id,
TeamId: s.th.BasicTeam.Id,
URL: "http://nowhere.com",
Method: model.CommandMethodPost,
Trigger: trigger,
}
command, _, err := c.CreateCommand(context.Background(), newCmd)
s.Require().NoError(err)
err = showCommandCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + trigger})
s.Require().NoError(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
outputCmd := printer.GetLines()[0].(*model.Command)
s.Require().Equal(command.TeamId, outputCmd.TeamId)
s.Require().Equal(command.Trigger, outputCmd.Trigger)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"strings"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/public/model"
)
// getCommandFromCommandArg retrieves a Command by command id or team:trigger.
func getCommandFromCommandArg(c client.Client, commandArg string) *model.Command {
if checkSlash(commandArg) {
return nil
}
cmd := getCommandFromTeamTrigger(c, commandArg)
if cmd == nil {
cmd, _, _ = c.GetCommandById(context.TODO(), commandArg)
}
return cmd
}
// getCommandFromTeamTrigger retrieves a Command via team:trigger syntax.
func getCommandFromTeamTrigger(c client.Client, teamTrigger string) *model.Command {
arr := strings.Split(teamTrigger, ":")
if len(arr) != 2 {
return nil
}
team, _, _ := c.GetTeamByName(context.TODO(), arr[0], "")
if team == nil {
return nil
}
trigger := arr[1]
if trigger == "" {
return nil
}
list, _, _ := c.ListCommands(context.TODO(), team.Id, false)
if list == nil {
return nil
}
for _, cmd := range list {
if cmd.Trigger == trigger {
return cmd
}
}
return nil
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/spf13/cobra"
)
func noCompletion(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
type validateArgsFn func(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
func validateArgsWithClient(fn validateArgsFn) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
ctx, cancel := context.WithTimeout(context.Background(), shellCompleteTimeout)
defer cancel()
c, _, _, err := getClient(ctx, cmd)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return fn(ctx, c, cmd, args, toComplete)
}
}
type fetcher[T any] func(ctx context.Context, c client.Client, page int, perPage int) ([]T, *model.Response, error) // fetcher calls the Mattermost API to fetch a list of entities T.
type matcher[T any] func(t T) []string // matcher returns list of field that are T uses for shell completion.
func fetchAndComplete[T any](f fetcher[T], m matcher[T]) validateArgsFn {
return func(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
res := []string{}
if toComplete == "" {
return res, cobra.ShellCompDirectiveNoFileComp
}
var page int
for {
entities, _, err := f(ctx, c, page, DefaultPageSize)
if err != nil {
// Return what we got so far
return res, cobra.ShellCompDirectiveNoFileComp
}
for _, e := range entities {
for _, field := range m(e) {
if strings.HasPrefix(field, toComplete) {
res = append(res, field)
// Only complete one field per entity.
break
}
}
}
if len(res) > shellCompletionMaxItems {
res = res[:shellCompletionMaxItems]
break
}
if len(entities) < DefaultPageSize {
break
}
page++
}
return res, cobra.ShellCompDirectiveNoFileComp
}
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
)
func TestFetchAndComplete(t *testing.T) {
type user struct {
name string
position string
}
createUsers := func(page, perPage int) []user {
ret := []user{}
for i := perPage * page; i < perPage*(page+1); i++ {
ret = append(ret, user{
name: fmt.Sprintf("name_%d", i),
position: fmt.Sprintf("position_%d", i),
})
}
return ret
}
listNames := func(n int) []string {
ret := []string{}
for i := range n {
ret = append(ret, fmt.Sprintf("name_%d", i))
}
return ret
}
for name, tc := range map[string]struct {
fetcher func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error)
matcher func(t user) []string
toComplete string
ExpectedCompletion []string
ExpectedDirective cobra.ShellCompDirective // Defaults to cobra.ShellCompDirectiveNoFileComp
}{
"empty query leads to empty result": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "bob"}, {name: "alice"}}, nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "",
ExpectedCompletion: []string{},
},
"no matches": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "bob"}, {name: "alice"}}, nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "x",
ExpectedCompletion: []string{},
},
"one element matches": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "bob"}, {name: "alice"}}, nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "b",
ExpectedCompletion: []string{"bob"},
},
"two element matches": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "anne"}, {name: "alice"}}, nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "a",
ExpectedCompletion: []string{"anne", "alice"},
},
"only match one fields per element": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "bob", position: "backend"}, {name: "alice"}}, nil, nil
},
matcher: func(t user) []string {
return []string{t.name, t.position}
},
toComplete: "b",
ExpectedCompletion: []string{"bob"},
},
"error ignored returns": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return []user{{name: "bob", position: "backend"}, {name: "alice"}}, nil, errors.New("some error")
},
matcher: func(t user) []string {
return []string{t.name, t.position}
},
toComplete: "b",
ExpectedCompletion: []string{},
},
"limit to 50": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return createUsers(0, 200), nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "name",
ExpectedCompletion: listNames(50),
},
"request multipile pages": {
fetcher: func(ctx context.Context, c client.Client, page int, perPage int) ([]user, *model.Response, error) {
return createUsers(page, perPage), nil, nil
},
matcher: func(t user) []string {
return []string{t.name}
},
toComplete: "name_4",
ExpectedCompletion: []string{
"name_4", "name_40", "name_41", "name_42", "name_43", "name_44", "name_45", "name_46", "name_47", "name_48", "name_49",
"name_400", "name_401", "name_402", "name_403", "name_404", "name_405", "name_406", "name_407", "name_408", "name_409", "name_410", "name_411", "name_412",
"name_413", "name_414", "name_415", "name_416", "name_417", "name_418", "name_419", "name_420", "name_421", "name_422", "name_423", "name_424", "name_425",
"name_426", "name_427", "name_428", "name_429", "name_430", "name_431", "name_432", "name_433", "name_434", "name_435", "name_436", "name_437", "name_438",
},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
comp, directive := fetchAndComplete(tc.fetcher, tc.matcher)(context.Background(), nil, nil, nil, tc.toComplete)
assert.Equal(t, tc.ExpectedCompletion, comp, name)
expectedDirective := cobra.ShellCompDirectiveNoFileComp
if tc.ExpectedDirective != 0 {
expectedDirective = tc.ExpectedDirective
}
assert.Equal(t, expectedDirective, directive, name)
})
}
}
func TestNoCompletion(t *testing.T) {
comp, directive := noCompletion(nil, nil, "any")
assert.Nil(t, comp)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"os"
"path"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"github.com/spf13/cobra"
)
var ComplianceExportCmd = &cobra.Command{
Use: "compliance-export",
Short: "Management of compliance exports",
}
var ComplianceExportListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List compliance export jobs, sorted by creation date descending (newest first)",
Args: cobra.NoArgs,
RunE: withClient(complianceExportListCmdF),
}
var ComplianceExportShowCmd = &cobra.Command{
Use: "show [complianceExportJobID]",
Example: "compliance-export show o98rj3ur83dp5dppfyk5yk6osy",
Short: "Show compliance export job",
Args: cobra.ExactArgs(1),
RunE: withClient(complianceExportShowCmdF),
}
var ComplianceExportCancelCmd = &cobra.Command{
Use: "cancel [complianceExportJobID]",
Example: "compliance-export cancel o98rj3ur83dp5dppfyk5yk6osy",
Short: "Cancel compliance export job",
Args: cobra.ExactArgs(1),
RunE: withClient(complianceExportCancelCmdF),
}
var ComplianceExportDownloadCmd = &cobra.Command{
Use: "download [complianceExportJobID] [output filepath (optional)]",
Example: "compliance-export download o98rj3ur83dp5dppfyk5yk6osy",
Short: "Download compliance export file",
Args: cobra.MinimumNArgs(1),
RunE: withClient(complianceExportDownloadCmdF),
}
var ComplianceExportCreateCmd = &cobra.Command{
Use: "create [complianceExportType] --date \"2025-03-27 -0400\"",
Example: "compliance-export create csv --date \"2025-03-27 -0400\"",
Long: "Create a compliance export job, of type 'csv' or 'actiance' or 'globalrelay'. If --date is set, the job will run for one day, from 12am to 12am (minus one millisecond) inclusively, in the format with timezone offset: `\"YYYY-MM-DD -0000\"`. E.g., \"2024-10-21 -0400\" for Oct 21, 2024 EDT timezone. \"2023-11-01 +0000\" for Nov 01, 2024 UTC. If set, the 'start' and 'end' flags will be ignored.\n\n" +
"Important: Running a compliance export job from mmctl will NOT affect the next scheduled job's batch_start_time. This means that if you run a compliance export job from mmctl, the next scheduled job will run from the batch_end_time of the previous scheduled job, as usual.",
Short: "Create a compliance export job, of type 'csv' or 'actiance' or 'globalrelay'",
Args: cobra.MinimumNArgs(1),
RunE: withClient(complianceExportCreateCmdF),
}
func init() {
ComplianceExportListCmd.Flags().Int("page", 0, "Page number to fetch for the list of compliance export jobs")
ComplianceExportListCmd.Flags().Int("per-page", DefaultPageSize, "Number of compliance export jobs to be fetched")
ComplianceExportListCmd.Flags().Bool("all", false, "Fetch all compliance export jobs. --page flag will be ignored if provided")
ComplianceExportDownloadCmd.Flags().Int("num-retries", 5, "Number of retries if the download fails")
ComplianceExportCreateCmd.Flags().String(
"date",
"",
"Run the export for one day, from 12am to 12am (minus one millisecond) inclusively, in the format with timezone offset: `\"YYYY-MM-DD -0000\"`. E.g., `\"2024-10-21 -0400\"` for Oct 21, 2024 EDT timezone. `\"2023-11-01 +0000\"` for Nov 01, 2024 UTC. If set, the 'start' and 'end' flags will be ignored.",
)
ComplianceExportCreateCmd.Flags().Int(
"start",
0,
"The start timestamp in unix milliseconds. Posts with updateAt >= start will be exported. If set, 'end' must be set as well. eg, `1743048000000` for 2025-03-27 EDT.",
)
ComplianceExportCreateCmd.Flags().Int(
"end",
0,
"The end timestamp in unix milliseconds. Posts with updateAt <= end will be exported. If set, 'start' must be set as well. eg, `1743134400000` for 2025-03-28 EDT.",
)
ComplianceExportCmd.AddCommand(
ComplianceExportListCmd,
ComplianceExportShowCmd,
ComplianceExportCancelCmd,
ComplianceExportDownloadCmd,
ComplianceExportCreateCmd,
)
RootCmd.AddCommand(ComplianceExportCmd)
}
func complianceExportListCmdF(c client.Client, command *cobra.Command, args []string) error {
return jobListCmdF(c, command, "message_export", "")
}
func complianceExportShowCmdF(c client.Client, command *cobra.Command, args []string) error {
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get compliance export job: %w", err)
}
printJob(job)
return nil
}
func complianceExportCancelCmdF(c client.Client, command *cobra.Command, args []string) error {
if _, err := c.CancelJob(context.TODO(), args[0]); err != nil {
return fmt.Errorf("failed to cancel compliance export job: %w", err)
}
return nil
}
func complianceExportDownloadCmdF(c client.Client, command *cobra.Command, args []string) error {
jobID := args[0]
var path string
if len(args) > 1 {
path = args[1]
} else {
path = jobID + ".zip"
}
retries, _ := command.Flags().GetInt("num-retries")
downloadFn := func(outFile *os.File) (string, error) {
return c.DownloadComplianceExport(context.TODO(), jobID, outFile)
}
suggestedFilename, err := downloadFile(path, downloadFn, retries, "compliance export")
if err != nil {
return err
}
// If we didn't provide a path and got a suggested filename, rename the file
if len(args) == 1 && suggestedFilename != "" && suggestedFilename != path {
// If the suggested name already exists, don't overwrite
if _, err := os.Stat(suggestedFilename); err == nil {
printer.PrintWarning(fmt.Sprintf("File with the server's suggested name %q already exists, keeping %q", suggestedFilename, path))
} else {
if err := os.Rename(path, suggestedFilename); err != nil {
printer.PrintWarning(fmt.Sprintf("Could not rename file to the server's suggested name %q: %v", suggestedFilename, err))
} else {
path = suggestedFilename
}
}
}
printer.Print(fmt.Sprintf("Compliance export file downloaded to %q", path))
return nil
}
func complianceExportCreateCmdF(c client.Client, command *cobra.Command, args []string) error {
exportType := args[0]
if exportType != model.ComplianceExportTypeActiance &&
exportType != model.ComplianceExportTypeCsv &&
exportType != model.ComplianceExportTypeGlobalrelay {
return fmt.Errorf("invalid export type: %s, must be one of: csv, actiance, globalrelay", exportType)
}
dateStr, err := command.Flags().GetString("date")
if err != nil {
return err
}
start, err := command.Flags().GetInt("start")
if err != nil {
return err
}
end, err := command.Flags().GetInt("end")
if err != nil {
return err
}
startTimestamp, endTimestamp, err := getStartAndEnd(dateStr, start, end)
if err != nil {
return err
}
startTime := strconv.FormatInt(startTimestamp, 10)
endTime := strconv.FormatInt(endTimestamp, 10)
exportDir := path.Join(model.ComplianceExportPath, fmt.Sprintf("%s-%s-%s", time.Now().Format(model.ComplianceExportDirectoryFormat), startTime, endTime))
// If start and end are 0, we need to not set those keys in the job data.
// This will make the job like a manual job (it will pick up where the previous job left off).
data := model.StringMap{
shared.JobDataInitiatedBy: "mmctl",
shared.JobDataExportType: exportType,
shared.JobDataBatchStartId: "",
shared.JobDataJobStartId: "",
}
if startTimestamp != 0 && endTimestamp != 0 {
data[shared.JobDataBatchStartTime] = startTime
data[shared.JobDataJobStartTime] = startTime
data[shared.JobDataJobEndTime] = endTime
data[shared.JobDataExportDir] = exportDir
}
job := &model.Job{
Type: model.JobTypeMessageExport,
Data: data,
}
if job, _, err = c.CreateJob(context.TODO(), job); err != nil {
return fmt.Errorf("failed to create compliance export job: %w", err)
}
printer.Print(fmt.Sprintf("Compliance export job created with ID: %s", job.Id))
return nil
}
// getStartAndEnd returns the start and end timestamps in unix milliseconds
func getStartAndEnd(dateStr string, start int, end int) (int64, int64, error) {
if dateStr == "" && start == 0 && end == 0 {
// return 0 so that the job will be like a manual job
return 0, 0, nil
}
if dateStr != "" && (start > 0 || end > 0) {
return 0, 0, errors.New("if date is used, start and end must not be set")
}
if dateStr != "" {
t, err := time.Parse("2006-01-02 -0700", dateStr)
if err != nil {
return 0, 0, fmt.Errorf("could not parse date string: %s, use the format with time zone offset: YYYY-MM-DD -0700, eg for EDT: `2024-12-24 -0400`, error details: %w", dateStr, err)
}
endTimestamp := t.AddDate(0, 0, 1).UnixMilli() - 1
return t.UnixMilli(), endTimestamp, nil
}
if start <= 0 || end <= 0 || start >= end {
return 0, 0, fmt.Errorf("if date is not used, start: %d and end: %d must both be > 0, and start must be < end", start, end)
}
return int64(start), int64(end), nil
}

View File

@@ -0,0 +1,744 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8"
st "github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestComplianceExportListCmdE2E() {
s.SetupMessageExportTestHelper()
s.Run("no permissions", func() {
printer.Clean()
cmd := makeCmd()
err := complianceExportListCmdF(s.th.Client, cmd, nil)
s.Require().EqualError(err, "failed to get jobs: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
jobType := model.JobTypeMessageExport
s.RunForSystemAdminAndLocal("List with no compliance export jobs", func(c client.Client) {
// Ensure no jobs exist
jobs, _, err := s.th.SystemAdminClient.GetJobsByType(context.Background(), jobType, 0, 1000)
s.Require().NoError(err)
for _, job := range jobs {
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}
cmd := makeCmd()
// Test default pagination
printer.Clean()
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("No jobs found", printer.GetLines()[0])
// Test with 1 per page
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("page", "0")
_ = cmd.Flags().Set("per-page", "1")
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("No jobs found", printer.GetLines()[0])
// Test with all items
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("all", "true")
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("No jobs found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("List compliance export jobs", func(c client.Client) {
now := model.GetMillis()
// Create 2 jobs
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
job2, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 100,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 100,
LastActivityAt: now - 100,
})
s.Require().NoError(err)
defer func() {
// Ensure jobs are deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
result, err = s.th.App.Srv().Store().Job().Delete(job2.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
// Test default pagination
printer.Clean()
cmd := makeCmd()
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(job2.Id, printer.GetLines()[0].(*model.Job).Id)
s.Require().Equal(job.Id, printer.GetLines()[1].(*model.Job).Id)
// Test with 1 per page
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("page", "0")
_ = cmd.Flags().Set("per-page", "1")
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job2.Id, printer.GetLines()[0].(*model.Job).Id)
// Test with all items
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("all", "true")
err = complianceExportListCmdF(c, cmd, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(job2.Id, printer.GetLines()[0].(*model.Job).Id)
s.Require().Equal(job.Id, printer.GetLines()[1].(*model.Job).Id)
})
}
func (s *MmctlE2ETestSuite) TestComplianceExportShowCmdE2E() {
s.SetupMessageExportTestHelper()
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
s.Run("no permissions", func() {
printer.Clean()
cmd := makeCmd()
err := complianceExportShowCmdF(s.th.Client, cmd, []string{job.Id})
s.Require().EqualError(err, "failed to get compliance export job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Show non-existent job", func(c client.Client) {
printer.Clean()
cmd := makeCmd()
err := complianceExportShowCmdF(c, cmd, []string{"non-existent-job-id"})
s.Require().EqualError(err, "failed to get compliance export job: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/jobs/non-existent-job-id'. Typo? are you missing a team_id or user_id as part of the url?")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Show existing job", func(c client.Client) {
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
printer.Clean()
cmd := makeCmd()
err = complianceExportShowCmdF(c, cmd, []string{job.Id})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(job.Id, printer.GetLines()[0].(*model.Job).Id)
})
}
func (s *MmctlE2ETestSuite) TestComplianceExportCancelCmdE2E() {
s.SetupMessageExportTestHelper()
s.Run("no permissions", func() {
printer.Clean()
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusInProgress,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
cmd := makeCmd()
err = complianceExportCancelCmdF(s.th.Client, cmd, []string{job.Id})
s.Require().EqualError(err, "failed to cancel compliance export job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Cancel non-existent job", func(c client.Client) {
printer.Clean()
cmd := makeCmd()
err := complianceExportCancelCmdF(c, cmd, []string{"non-existent-job-id"})
s.Require().EqualError(err, "failed to cancel compliance export job: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/jobs/non-existent-job-id/cancel'. Typo? are you missing a team_id or user_id as part of the url?")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Cancel existing job", func(c client.Client) {
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusInProgress,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
printer.Clean()
cmd := makeCmd()
err = complianceExportCancelCmdF(c, cmd, []string{job.Id})
s.Require().NoError(err)
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
// Verify job was cancelled
job, _, err = s.th.SystemAdminClient.GetJob(context.Background(), job.Id)
s.Require().NoError(err)
s.Require().Equal(model.JobStatusCanceled, job.Status)
})
s.RunForSystemAdminAndLocal("Error cancelling job in non-cancellable state", func(c client.Client) {
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusInProgress,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
_, err = s.th.SystemAdminClient.UpdateJobStatus(context.Background(), job.Id, model.JobStatusCanceled, true)
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
printer.Clean()
cmd := makeCmd()
err = complianceExportCancelCmdF(c, cmd, []string{job.Id})
s.Require().EqualError(err, "failed to cancel compliance export job: Could not request cancellation for job that is not in a cancelable state.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
}
func (s *MmctlE2ETestSuite) TestComplianceExportDownloadCmdE2E() {
s.SetupMessageExportTestHelper()
s.Run("no permissions", func() {
printer.Clean()
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
cmd := makeCmd()
cmd.Flags().Int("num-retries", 0, "")
err = complianceExportDownloadCmdF(s.th.Client, cmd, []string{job.Id})
s.Require().EqualError(err, "failed to download compliance export after 0 retries: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Download non-existent job", func(c client.Client) {
printer.Clean()
cmd := makeCmd()
cmd.Flags().Int("num-retries", 0, "")
err := complianceExportDownloadCmdF(c, cmd, []string{"non-existent-job-id"})
s.Require().EqualError(err, "failed to download compliance export after 0 retries: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/jobs/non-existent-job-id/download'. Typo? are you missing a team_id or user_id as part of the url?")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("existing, non empty compliance export", func(c client.Client) {
printer.Clean()
importFilePath := filepath.Join(server.GetPackagePath(), "test.zip")
f, err := os.Create(importFilePath)
s.Require().NoError(err)
_, err = f.WriteString("test data")
s.Require().NoError(err)
err = f.Close()
s.Require().NoError(err)
defer func() {
err = os.Remove(importFilePath)
s.Require().NoError(err)
}()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 0, "")
err = complianceExportDownloadCmdF(c, cmd, []string{"jobId", importFilePath})
s.Require().EqualError(err, "compliance export file already exists")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
printer.Clean()
downloadPath := "explicit_path.zip"
defer os.Remove(downloadPath)
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
s.Require().NoError(err)
exportDir := "job_test_export"
// Create a compliance export with two zip files
exportFilePath := filepath.Join(serverDataDir, exportDir)
err = os.Mkdir(exportFilePath, 0755)
s.Require().NoError(err)
defer func() {
err = os.RemoveAll(exportFilePath)
s.Require().NoError(err)
}()
// Create first zip file
zipPath1 := exportFilePath + "/export1.zip"
f1, err := os.Create(zipPath1)
s.Require().NoError(err)
_, err = f1.WriteString("test data 1")
s.Require().NoError(err)
err = f1.Close()
s.Require().NoError(err)
// Create second zip file
zipPath2 := exportFilePath + "/export2.zip"
f2, err := os.Create(zipPath2)
s.Require().NoError(err)
_, err = f2.WriteString("test data 2")
s.Require().NoError(err)
err = f2.Close()
s.Require().NoError(err)
s.T().Cleanup(func() {
err = os.RemoveAll(exportFilePath)
s.Require().NoError(err)
})
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
cmd := makeCmd()
cmd.Flags().Int("num-retries", 0, "")
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id, downloadPath})
s.Require().NoError(err)
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", downloadPath))
s.Require().Empty(printer.GetErrorLines())
// Verify the file was downloaded
_, err = os.Stat(downloadPath)
s.Require().NoError(err)
defer os.Remove(downloadPath)
// Verify the file is a zip with a directory with two zip files
zipReader, err := zip.OpenReader(downloadPath)
s.Require().NoError(err)
defer zipReader.Close()
// Check that we have the expected files in the zip
foundExport1 := false
foundExport2 := false
for _, file := range zipReader.File {
if file.Name == "export1.zip" {
foundExport1 = true
// Verify contents of export1.zip
rc, err := file.Open()
s.Require().NoError(err)
content, err := io.ReadAll(rc)
s.Require().NoError(err)
s.Require().Equal("test data 1", string(content))
rc.Close()
} else if file.Name == "export2.zip" {
foundExport2 = true
// Verify contents of export2.zip
rc, err := file.Open()
s.Require().NoError(err)
content, err := io.ReadAll(rc)
s.Require().NoError(err)
s.Require().Equal("test data 2", string(content))
rc.Close()
} else {
s.Failf("unexpected file found in downloaded zip", file.Name)
}
}
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
})
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
printer.Clean()
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
s.Require().NoError(err)
exportDir := "job_test_export"
expectedDownloadPath := exportDir + ".zip"
// Create a compliance export with two zip files
exportFilePath := filepath.Join(serverDataDir, exportDir)
err = os.Mkdir(exportFilePath, 0755)
s.Require().NoError(err)
defer func() {
err = os.RemoveAll(exportFilePath)
s.Require().NoError(err)
// also remove the downloaded file
err = os.Remove(expectedDownloadPath)
s.Require().NoError(err)
}()
// Create first zip file
zipPath1 := exportFilePath + "/export1.zip"
f1, err := os.Create(zipPath1)
s.Require().NoError(err)
_, err = f1.WriteString("test data 1")
s.Require().NoError(err)
err = f1.Close()
s.Require().NoError(err)
// Create second zip file
zipPath2 := exportFilePath + "/export2.zip"
f2, err := os.Create(zipPath2)
s.Require().NoError(err)
_, err = f2.WriteString("test data 2")
s.Require().NoError(err)
err = f2.Close()
s.Require().NoError(err)
defer func() {
err = os.RemoveAll(exportFilePath)
s.Require().NoError(err)
}()
now := model.GetMillis()
// Create a job
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
Id: st.NewTestID(),
CreateAt: now - 1000,
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: now - 1000,
LastActivityAt: now - 1000,
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
})
s.Require().NoError(err)
defer func() {
// Ensure job is deleted from the database
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}()
cmd := makeCmd()
cmd.Flags().Int("num-retries", 0, "")
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id})
s.Require().NoError(err)
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", expectedDownloadPath))
s.Require().Empty(printer.GetErrorLines())
// Verify the file was downloaded
_, err = os.Stat(expectedDownloadPath)
s.Require().NoError(err)
// Verify the file is a zip with a directory with two zip files
zipReader, err := zip.OpenReader(expectedDownloadPath)
s.Require().NoError(err)
defer zipReader.Close()
// Check that we have the expected files in the zip
foundExport1 := false
foundExport2 := false
for _, file := range zipReader.File {
if file.Name == "export1.zip" {
foundExport1 = true
// Verify contents of export1.zip
rc, err := file.Open()
s.Require().NoError(err)
content, err := io.ReadAll(rc)
s.Require().NoError(err)
s.Require().Equal("test data 1", string(content))
rc.Close()
} else if file.Name == "export2.zip" {
foundExport2 = true
// Verify contents of export2.zip
rc, err := file.Open()
s.Require().NoError(err)
content, err := io.ReadAll(rc)
s.Require().NoError(err)
s.Require().Equal("test data 2", string(content))
rc.Close()
} else {
s.Failf("unexpected file found in downloaded zip", file.Name)
}
}
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
})
}
func (s *MmctlE2ETestSuite) TestComplianceExportMmctlJobStartTimeE2E() {
s.SetupMessageExportTestHelper()
s.RunForSystemAdminAndLocal("mmctl job uses batch_start_time from previous regular job", func(c client.Client) {
// Ensure no jobs exist before we start
jobs, _, err := s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 1000)
s.Require().NoError(err)
for _, job := range jobs {
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}
now := model.GetMillis()
// Create a regular (non-mmctl) export job
regularStartTime := now - 10000
regularEndTime := now - 5000
regularJob := s.runJobForTest(map[string]string{
shared.JobDataBatchStartTime: strconv.FormatInt(regularStartTime, 10),
shared.JobDataJobEndTime: strconv.FormatInt(regularEndTime, 10),
})
s.Require().Equal(model.JobStatusSuccess, regularJob.Status, "Regular job should complete successfully")
s.Require().NotEmpty(regularJob.Data[shared.JobDataBatchStartTime], "Regular job should have a batch start time")
regularJobBatchStartTime := regularJob.Data[shared.JobDataBatchStartTime]
// Run an mmctl-initiated export job
cmd := &cobra.Command{}
cmd.Flags().String("date", "", "")
cmd.Flags().Int("start", 0, "")
cmd.Flags().Int("end", 0, "")
err = complianceExportCreateCmdF(c, cmd, []string{model.ComplianceExportTypeActiance})
s.Require().NoError(err, "Should create mmctl job successfully")
// Find the mmctl job
jobs, _, err = s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 10)
s.Require().NoError(err)
s.Require().True(len(jobs) > 1, "Should have at least 2 jobs")
// The most recent job should be the mmctl job
mmctlJob := jobs[0]
s.Require().Equal("mmctl", mmctlJob.Data[shared.JobDataInitiatedBy])
// Wait for the mmctl job to complete
s.checkJobForStatus(mmctlJob.Id, model.JobStatusSuccess)
mmctlJob = s.getMostRecentJobWithId(mmctlJob.Id)
// The job_start_time should match the batch_start_time from the previous regular job
s.Require().Equal(regularJobBatchStartTime, mmctlJob.Data[shared.JobDataJobStartTime],
"mmctl job should use batch_start_time from previous regular job as its job_start_time")
// Clean up jobs
for _, job := range jobs {
result, err := s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}
})
s.RunForSystemAdminAndLocal("mmctl job ignores previous mmctl jobs and uses regular job", func(c client.Client) {
// Ensure no jobs exist before we start
jobs, _, err := s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 1000)
s.Require().NoError(err)
for _, job := range jobs {
var result string
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}
now := model.GetMillis()
// Create a regular (non-mmctl) export job
regularStartTime := now - 10000
regularEndTime := now - 5000
regularJob := s.runJobForTest(map[string]string{
shared.JobDataBatchStartTime: strconv.FormatInt(regularStartTime, 10),
shared.JobDataJobEndTime: strconv.FormatInt(regularEndTime, 10),
})
s.Require().Equal(model.JobStatusSuccess, regularJob.Status, "Regular job should complete successfully")
s.Require().NotEmpty(regularJob.Data[shared.JobDataBatchStartTime], "Regular job should have a batch start time")
regularJobBatchStartTime := regularJob.Data[shared.JobDataBatchStartTime]
// Run an mmctl-initiated export job with an explicit start time (different from the regular job)
cmd := &cobra.Command{}
cmd.Flags().String("date", "", "")
cmd.Flags().Int("start", int(now-2000), "")
cmd.Flags().Int("end", int(now-1000), "")
err = complianceExportCreateCmdF(c, cmd, []string{model.ComplianceExportTypeActiance})
s.Require().NoError(err, "Should create first mmctl job successfully")
// Find the mmctl job
jobs, _, err = s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 10)
s.Require().NoError(err)
s.Require().True(len(jobs) > 1, "Should have at least 2 jobs")
// The most recent job should be the mmctl job
mmctlJob1 := jobs[0]
s.Require().Equal("mmctl", mmctlJob1.Data[shared.JobDataInitiatedBy])
// Wait for the mmctl job to complete
s.checkJobForStatus(mmctlJob1.Id, model.JobStatusSuccess)
mmctlJob1 = s.getMostRecentJobWithId(mmctlJob1.Id)
// Verify this job has a different batch_start_time than the regular job
s.Require().NotEqual(regularJobBatchStartTime, mmctlJob1.Data[shared.JobDataBatchStartTime],
"First mmctl job should have a different batch_start_time than regular job")
// Run a second mmctl-initiated export job WITHOUT a specified start time
cmd = &cobra.Command{}
cmd.Flags().String("date", "", "")
cmd.Flags().Int("start", 0, "")
cmd.Flags().Int("end", 0, "")
err = complianceExportCreateCmdF(c, cmd, []string{model.ComplianceExportTypeActiance})
s.Require().NoError(err, "Should create second mmctl job successfully")
// Find the second mmctl job
jobs, _, err = s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 10)
s.Require().NoError(err)
s.Require().True(len(jobs) > 2, "Should have at least 3 jobs")
// The most recent job should be the second mmctl job
mmctlJob2 := jobs[0]
s.Require().Equal("mmctl", mmctlJob2.Data[shared.JobDataInitiatedBy])
// Wait for the second mmctl job to complete
s.checkJobForStatus(mmctlJob2.Id, model.JobStatusSuccess)
mmctlJob2 = s.getMostRecentJobWithId(mmctlJob2.Id)
// The job_start_time of the second mmctl job should match the batch_start_time from the regular job,
// not from the mmctl job that ran in between
s.Require().Equal(regularJobBatchStartTime, mmctlJob2.Data[shared.JobDataJobStartTime],
"Second mmctl job should use batch_start_time from previous regular job as its job_start_time, not from previous mmctl job")
s.Require().NotEqual(mmctlJob1.Data[shared.JobDataBatchStartTime], mmctlJob2.Data[shared.JobDataJobStartTime],
"Second mmctl job should not use batch_start_time from previous mmctl job as its job_start_time")
// Clean up jobs
for _, job := range jobs {
result, err := s.th.App.Srv().Store().Job().Delete(job.Id)
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
}
})
}

View File

@@ -0,0 +1,430 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"os"
"testing"
gomock "github.com/golang/mock/gomock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestComplianceExportListCmdF() {
s.Run("list default pagination", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
var mockJobs []*model.Job
// Test with default pagination
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 0, DefaultPageSize).
Return(mockJobs, &model.Response{}, nil).
Times(1)
cmd := makeCmd()
err := complianceExportListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal("No jobs found", printer.GetLines()[0])
// Test with 10 per page
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("per-page", "10")
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 0, 10).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err = complianceExportListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal("No jobs found", printer.GetLines()[0])
// Test with all items
printer.Clean()
cmd = makeCmd()
_ = cmd.Flags().Set("all", "true")
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 0, DefaultPageSize).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err = complianceExportListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal("No jobs found", printer.GetLines()[0])
})
s.Run("list with paging", func() {
// Create 5 mock jobs
mockJobs := make([]*model.Job, 5)
for i := range 5 {
mockJobs[i] = &model.Job{
Id: model.NewId(),
CreateAt: model.GetMillis() - int64(i*1000),
}
}
// Test paging with 2 jobs per page
printer.Clean()
cmd := makeCmd()
_ = cmd.Flags().Set("all", "true")
_ = cmd.Flags().Set("per-page", "2")
// Expect 4 API calls (2 jobs each for first 2 pages, 1 job for last page, then a call with 0 jobs)
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 0, 2).
Return(mockJobs[0:2], &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 1, 2).
Return(mockJobs[2:4], &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 2, 2).
Return(mockJobs[4:5], &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetJobs(context.TODO(), "message_export", "", 3, 2).
Return(mockJobs[5:], &model.Response{}, nil).
Times(1)
err := complianceExportListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 5)
s.Len(printer.GetErrorLines(), 0)
// Verify jobs are printed in correct order
for i := range 5 {
s.Equal(mockJobs[i].Id, printer.GetLines()[i].(*model.Job).Id)
}
})
}
func (s *MmctlUnitTestSuite) TestComplianceExportShowCmdF() {
s.Run("show job successfully", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
mockJob := &model.Job{
Id: model.NewId(),
CreateAt: model.GetMillis(),
Type: model.JobTypeMessageExport,
}
s.client.
EXPECT().
GetJob(context.TODO(), mockJob.Id).
Return(mockJob, &model.Response{}, nil).
Times(1)
cmd := makeCmd()
err := complianceExportShowCmdF(s.client, cmd, []string{mockJob.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("show job with error", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
mockError := &model.AppError{
Message: "failed to get job",
}
s.client.
EXPECT().
GetJob(context.TODO(), "invalid-job-id").
Return(nil, &model.Response{}, mockError).
Times(1)
cmd := makeCmd()
err := complianceExportShowCmdF(s.client, cmd, []string{"invalid-job-id"})
s.Require().NotNil(err)
s.EqualError(err, "failed to get compliance export job: failed to get job")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestComplianceExportCancelCmdF() {
s.Run("cancel job successfully", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
id := model.NewId()
s.client.
EXPECT().
CancelJob(context.TODO(), id).
Return(&model.Response{}, nil).
Times(1)
cmd := makeCmd()
err := complianceExportCancelCmdF(s.client, cmd, []string{id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("cancel job with get error", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
mockError := &model.AppError{
Message: "failed to get job",
}
s.client.
EXPECT().
CancelJob(context.TODO(), "invalid-job-id").
Return(&model.Response{}, mockError).
Times(1)
cmd := makeCmd()
err := complianceExportCancelCmdF(s.client, cmd, []string{"invalid-job-id"})
s.Require().NotNil(err)
s.EqualError(err, "failed to cancel compliance export job: failed to get job")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("cancel job with cancel error", func() {
s.SetupTest() // Reset mocks before test
printer.Clean()
id := model.NewId()
mockError := &model.AppError{
Message: "failed to cancel job",
}
s.client.
EXPECT().
CancelJob(context.TODO(), id).
Return(&model.Response{}, mockError).
Times(1)
cmd := makeCmd()
err := complianceExportCancelCmdF(s.client, cmd, []string{id})
s.Require().NotNil(err)
s.EqualError(err, "failed to cancel compliance export job: failed to cancel job")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestComplianceExportDownloadCmdF() {
mockJob := &model.Job{
Id: model.NewId(),
CreateAt: model.GetMillis(),
Type: model.JobTypeMessageExport,
}
s.Run("download job file successfully", func() {
printer.Clean()
s.T().Cleanup(func() {
err := os.Remove("suggested-filename.zip")
s.NoError(err)
})
s.client.
EXPECT().
DownloadComplianceExport(gomock.Any(), mockJob.Id, gomock.Any()).
Return("suggested-filename.zip", nil).
Times(1)
cmd := makeCmd()
cmd.Flags().Int("num-retries", 5, "")
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "suggested-filename.zip"), printer.GetLines()[0])
})
s.Run("download job file with explicit path", func() {
printer.Clean()
s.T().Cleanup(func() {
err := os.Remove("custom-path.zip")
s.NoError(err)
})
s.client.
EXPECT().
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
Return("", nil).
Times(1)
cmd := makeCmd()
cmd.Flags().Int("num-retries", 5, "")
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id, "custom-path.zip"})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "custom-path.zip"), printer.GetLines()[0])
})
s.Run("download job with error", func() {
printer.Clean()
mockError := &model.AppError{
Message: "failed to download file",
}
s.client.
EXPECT().
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
Return("", mockError).
Times(6) // Initial attempt + 5 retries
cmd := makeCmd()
cmd.Flags().Int("num-retries", 5, "")
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
s.Require().NotNil(err)
s.EqualError(err, "failed to download compliance export after 5 retries: failed to download file")
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
// Ensure output file does not exist
_, err = os.Stat(mockJob.Id + "BAR.zip")
s.Error(err)
s.True(os.IsNotExist(err))
})
}
func TestGetStartAndEnd(t *testing.T) {
type args struct {
dateStr string
start int
end int
}
tests := []struct {
name string
args args
expectedStart int64
expectedEnd int64
wantErr bool
}{
// check with: https://www.epochconverter.com/
{
name: "parse a date in EDT (-0400)",
args: args{
dateStr: "2024-10-21 -0400",
},
expectedStart: 1729483200000,
expectedEnd: 1729569599999,
},
{
name: "parse a date in UTC (+0)",
args: args{
dateStr: "2024-10-21 +0000",
},
expectedStart: 1729468800000,
expectedEnd: 1729555199999,
},
{
name: "parse a date in CDT (-0500)",
args: args{
dateStr: "2024-10-21 -0500",
},
expectedStart: 1729486800000,
expectedEnd: 1729573199999,
},
{
name: "bad format",
args: args{
dateStr: "2024-10-21 CT",
},
wantErr: true,
},
{
name: "bad format",
args: args{
dateStr: "2024-1-2 CDT",
},
wantErr: true,
},
{
name: "it's ok to not have date, start, or end",
args: args{},
wantErr: false,
},
{
name: "needs both start and end pt1",
args: args{
start: 12345,
},
wantErr: true,
},
{
name: "needs both start and end pt2",
args: args{
end: 12345,
},
wantErr: true,
},
{
name: "start and end",
args: args{
start: 12345,
end: 678912,
},
expectedStart: 12345,
expectedEnd: 678912,
wantErr: false,
},
{
name: "date and start",
args: args{
dateStr: "2024-10-21 -0400",
start: 12345,
},
wantErr: true,
},
{
name: "date and end",
args: args{
dateStr: "2024-10-21 -0400",
end: 678912,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStart, gotEnd, err := getStartAndEnd(tt.args.dateStr, tt.args.start, tt.args.end)
if (err != nil) != tt.wantErr {
t.Errorf("getStartAndEnd() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotStart != tt.expectedStart {
t.Errorf("getStartAndEnd() got = %v, want %v", gotStart, tt.expectedStart)
}
if gotEnd != tt.expectedEnd {
t.Errorf("getStartAndEnd() got1 = %v, want %v", gotEnd, tt.expectedEnd)
}
})
}
}
func makeCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", DefaultPageSize, "")
cmd.Flags().Bool("all", false, "")
return cmd
}

View File

@@ -0,0 +1,613 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"reflect"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
const defaultEditor = "vi"
var ErrConfigInvalidPath = errors.New("selected path object is not valid")
var ConfigCmd = &cobra.Command{
Use: "config",
Short: "Configuration",
}
var ConfigGetCmd = &cobra.Command{
Use: "get",
Short: "Get config setting",
Long: "Gets the value of a config setting by its name in dot notation.",
Example: `config get SqlSettings.DriverName`,
Args: cobra.ExactArgs(1),
RunE: withClient(configGetCmdF),
}
var ConfigSetCmd = &cobra.Command{
Use: "set",
Short: "Set config setting",
Long: "Sets the value of a config setting by its name in dot notation. Accepts multiple values for array settings",
Example: "config set SqlSettings.DriverName postgres\nconfig set SqlSettings.DataSourceReplicas \"replica1\" \"replica2\"",
Args: cobra.MinimumNArgs(2),
RunE: withClient(configSetCmdF),
}
var ConfigPatchCmd = &cobra.Command{
Use: "patch <config-file>",
Short: "Patch the config",
Long: "Patches config settings with the given config file.",
Example: "config patch /path/to/config.json",
Args: cobra.ExactArgs(1),
RunE: withClient(configPatchCmdF),
}
var ConfigEditCmd = &cobra.Command{
Use: "edit",
Short: "Edit the config",
Long: "Opens the editor defined in the EDITOR environment variable to modify the server's configuration and then uploads it",
Example: "config edit",
Args: cobra.NoArgs,
RunE: withClient(configEditCmdF),
}
var ConfigResetCmd = &cobra.Command{
Use: "reset",
Short: "Reset config setting",
Long: "Resets the value of a config setting by its name in dot notation or a setting section. Accepts multiple values for array settings.",
Example: "config reset SqlSettings.DriverName LogSettings",
Args: cobra.MinimumNArgs(1),
RunE: withClient(configResetCmdF),
}
var ConfigShowCmd = &cobra.Command{
Use: "show",
Short: "Writes the server configuration to STDOUT",
Long: "Prints the server configuration and writes to STDOUT in JSON format.",
Example: "config show",
Args: cobra.NoArgs,
RunE: withClient(configShowCmdF),
}
var ConfigReloadCmd = &cobra.Command{
Use: "reload",
Short: "Reload the server configuration",
Long: "Reload the server configuration in case you want to new settings to be applied.",
Example: "config reload",
Args: cobra.NoArgs,
RunE: withClient(configReloadCmdF),
}
var ConfigMigrateCmd = &cobra.Command{
Use: "migrate [from_config] [to_config]",
Short: "Migrate existing config between backends",
Long: "Migrate a file-based configuration to (or from) a database-based configuration. Point the Mattermost server at the target configuration to start using it. Note that this command is only available in `--local` mode.",
Example: `config migrate path/to/config.json "postgres://mmuser:mostest_password@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10"`,
Args: cobra.ExactArgs(2),
RunE: withClient(configMigrateCmdF),
}
var ConfigSubpathCmd = &cobra.Command{
Use: "subpath",
Short: "Update client asset loading to use the configured subpath",
Long: "Update the hard-coded production client asset paths to take into account Mattermost running on a subpath. This command needs access to the Mattermost assets directory to be able to rewrite the paths.",
Example: ` # you can rewrite the assets to use a subpath
mmctl config subpath --assets-dir /opt/mattermost/client --path /mattermost
# the subpath can have multiple steps
mmctl config subpath --assets-dir /opt/mattermost/client --path /my/custom/subpath
# or you can fallback to the root path passing /
mmctl config subpath --assets-dir /opt/mattermost/client --path /`,
Args: cobra.NoArgs,
RunE: configSubpathCmdF,
}
var ConfigExportCmd = &cobra.Command{
Use: "export",
Short: "Export the server configuration",
Long: "Export the server configuration in case you want to import somewhere else.",
Example: "config export --remove-masked --remove-defaults",
Args: cobra.NoArgs,
RunE: withClient(configExportCmdF),
}
func init() {
ConfigResetCmd.Flags().Bool("confirm", false, "confirm you really want to reset all configuration settings to its default value")
ConfigSubpathCmd.Flags().StringP("assets-dir", "a", "", "directory of the Mattermost assets in the local filesystem")
_ = ConfigSubpathCmd.MarkFlagRequired("assets-dir")
ConfigSubpathCmd.Flags().StringP("path", "p", "", "path to update the assets with")
_ = ConfigSubpathCmd.MarkFlagRequired("path")
ConfigExportCmd.Flags().Bool("remove-masked", true, "remove masked values from the exported configuration")
ConfigExportCmd.Flags().Bool("remove-defaults", false, "remove default values from the exported configuration")
ConfigCmd.AddCommand(
ConfigGetCmd,
ConfigSetCmd,
ConfigPatchCmd,
ConfigEditCmd,
ConfigResetCmd,
ConfigShowCmd,
ConfigReloadCmd,
ConfigMigrateCmd,
ConfigSubpathCmd,
ConfigExportCmd,
)
RootCmd.AddCommand(ConfigCmd)
}
func getValue(path []string, obj any) (any, bool) {
r := reflect.ValueOf(obj)
var val reflect.Value
if r.Kind() == reflect.Map {
val = r.MapIndex(reflect.ValueOf(path[0]))
if val.IsValid() {
val = val.Elem()
}
} else {
val = r.FieldByName(path[0])
}
if !val.IsValid() {
return nil, false
}
switch {
case len(path) == 1:
return val.Interface(), true
case val.Kind() == reflect.Struct:
return getValue(path[1:], val.Interface())
case val.Kind() == reflect.Map:
remainingPath := strings.Join(path[1:], ".")
mapIter := val.MapRange()
for mapIter.Next() {
key := mapIter.Key().String()
if strings.HasPrefix(remainingPath, key) {
i := strings.Count(key, ".") + 2 // number of dots + a dot on each side
mapVal := mapIter.Value()
// if no sub field path specified, return the object
if len(path[i:]) == 0 {
return mapVal.Interface(), true
}
data := mapVal.Interface()
if mapVal.Kind() == reflect.Pointer {
data = mapVal.Elem().Interface() // if value is a pointer, dereference it
}
// pass subpath
return getValue(path[i:], data)
}
}
}
return nil, false
}
func setValueWithConversion(val reflect.Value, newValue any) error {
switch val.Kind() {
case reflect.Struct:
val.Set(reflect.ValueOf(newValue))
return nil
case reflect.Slice:
v := reflect.ValueOf(newValue)
if v.Kind() != reflect.Slice {
// Special case when setting a string to a byte slice
if val.Type().Elem().Kind() == reflect.Uint8 && v.Kind() == reflect.String {
s := newValue.(string)
v = reflect.ValueOf([]byte(s))
} else {
return errors.Errorf("target value is of type %v and provided value is %v", val.Kind(), v.Kind())
}
}
val.Set(v)
return nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
bits := val.Type().Bits()
v, err := strconv.ParseInt(newValue.(string), 10, bits)
if err != nil {
return fmt.Errorf("target value is of type %v and provided value is not, err: %w", val.Kind(), err)
}
val.SetInt(v)
return nil
case reflect.Float32, reflect.Float64:
bits := val.Type().Bits()
v, err := strconv.ParseFloat(newValue.(string), bits)
if err != nil {
return fmt.Errorf("target value is of type %v and provided value is not, err: %w", val.Kind(), err)
}
val.SetFloat(v)
return nil
case reflect.String:
val.SetString(newValue.(string))
return nil
case reflect.Bool:
v, err := strconv.ParseBool(newValue.(string))
if err != nil {
return fmt.Errorf("target value is of type %v and provided value is not, err: %w", val.Kind(), err)
}
val.SetBool(v)
return nil
default:
return errors.Errorf("value type %v is not supported", val.Kind())
}
}
func setValue(path []string, obj reflect.Value, newValue any) error {
var val reflect.Value
switch obj.Kind() {
case reflect.Struct:
val = obj.FieldByName(path[0])
case reflect.Map:
val = obj.MapIndex(reflect.ValueOf(path[0]))
if val.IsValid() {
val = val.Elem()
}
default:
val = obj
}
if val.Kind() == reflect.Invalid {
return ErrConfigInvalidPath
}
if len(path) == 1 {
if val.Kind() == reflect.Pointer {
return setValue(path, val.Elem(), newValue)
} else if obj.Kind() == reflect.Map {
// since we cannot set map elements directly, we clone the value, set it, and then put it back in the map
mapKey := reflect.ValueOf(path[0])
subVal := obj.MapIndex(mapKey)
if subVal.IsValid() {
tmpVal := reflect.New(subVal.Elem().Type())
if err := setValueWithConversion(tmpVal.Elem(), newValue); err != nil {
return err
}
obj.SetMapIndex(mapKey, tmpVal)
return nil
}
}
return setValueWithConversion(val, newValue)
}
if val.Kind() == reflect.Struct {
return setValue(path[1:], val, newValue)
} else if val.Kind() == reflect.Map {
remainingPath := strings.Join(path[1:], ".")
mapIter := val.MapRange()
for mapIter.Next() {
key := mapIter.Key().String()
if strings.HasPrefix(remainingPath, key) {
mapVal := mapIter.Value()
if mapVal.Kind() == reflect.Pointer {
mapVal = mapVal.Elem() // if value is a pointer, dereference it
}
i := len(strings.Split(key, ".")) + 1
if i > len(path)-1 { // leaf element
i = 1
mapVal = val
}
// pass subpath
return setValue(path[i:], mapVal, newValue)
}
}
}
return errors.New("path object type is not supported")
}
func setConfigValue(path []string, config *model.Config, newValue []string) error {
if len(newValue) > 1 {
return setValue(path, reflect.ValueOf(config).Elem(), newValue)
}
return setValue(path, reflect.ValueOf(config).Elem(), newValue[0])
}
func resetConfigValue(path []string, config *model.Config, newValue any) error {
nv := reflect.ValueOf(newValue)
if nv.Kind() == reflect.Pointer {
switch nv.Elem().Kind() {
case reflect.Int:
return setValue(path, reflect.ValueOf(config).Elem(), strconv.Itoa(*newValue.(*int)))
case reflect.Bool:
return setValue(path, reflect.ValueOf(config).Elem(), strconv.FormatBool(*newValue.(*bool)))
default:
return setValue(path, reflect.ValueOf(config).Elem(), *newValue.(*string))
}
} else {
return setValue(path, reflect.ValueOf(config).Elem(), newValue)
}
}
func configGetCmdF(c client.Client, _ *cobra.Command, args []string) error {
printer.SetSingle(true)
printer.SetFormat(printer.FormatJSON)
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
path := strings.Split(args[0], ".")
val, ok := getValue(path, *config)
if !ok {
return errors.New("invalid key")
}
if cloudRestricted(config, path) && reflect.ValueOf(val).IsNil() {
return fmt.Errorf("accessing this config path: %s is restricted in a cloud environment", args[0])
}
printer.Print(val)
return nil
}
func configSetCmdF(c client.Client, _ *cobra.Command, args []string) error {
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
path := parseConfigPath(args[0])
if cErr := setConfigValue(path, config, args[1:]); cErr != nil {
if errors.Is(cErr, ErrConfigInvalidPath) && cloudRestricted(config, path) {
return fmt.Errorf("changing this config path: %s is restricted in a cloud environment", args[0])
}
return cErr
}
newConfig, _, err := c.PatchConfig(context.TODO(), config)
if err != nil {
return err
}
printer.PrintT("Value changed successfully", newConfig)
return nil
}
func configPatchCmdF(c client.Client, _ *cobra.Command, args []string) error {
configBytes, err := os.ReadFile(args[0])
if err != nil {
return err
}
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
// get original plugin map
var pluginConfig map[string]map[string]any
if config.PluginSettings.Plugins != nil {
pluginConfig = (config.Clone()).PluginSettings.Plugins
}
// apply path onto the existing config
if jErr := json.Unmarshal(configBytes, config); jErr != nil {
return jErr
}
// merge config plugin map on top of the original, and assign the
// result to the config key
config.PluginSettings.Plugins = MergePluginConfigs(pluginConfig, config.PluginSettings.Plugins)
newConfig, _, err := c.PatchConfig(context.TODO(), config)
if err != nil {
return err
}
printer.PrintT("Config patched successfully", newConfig)
return nil
}
func configEditCmdF(c client.Client, _ *cobra.Command, _ []string) error {
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
configBytes, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
file, err := os.CreateTemp(os.TempDir(), "mmctl-*.json")
if err != nil {
return err
}
defer func() {
file.Close()
os.Remove(file.Name())
}()
if _, writeErr := file.Write(configBytes); writeErr != nil {
return writeErr
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = defaultEditor
}
editorCmd := exec.Command(editor, file.Name())
editorCmd.Stdout = os.Stdout
editorCmd.Stdin = os.Stdin
editorCmd.Stderr = os.Stderr
if cmdErr := editorCmd.Run(); cmdErr != nil {
return cmdErr
}
newConfigBytes, err := os.ReadFile(file.Name())
if err != nil {
return err
}
if jErr := json.Unmarshal(newConfigBytes, config); jErr != nil {
return jErr
}
newConfig, _, err := c.UpdateConfig(context.TODO(), config)
if err != nil {
return err
}
printer.PrintT("Config updated successfully", newConfig)
return nil
}
func configResetCmdF(c client.Client, cmd *cobra.Command, args []string) error {
confirmFlag, _ := cmd.Flags().GetBool("confirm")
if !confirmFlag && len(args) > 0 {
if err := getConfirmation(fmt.Sprintf(
"Are you sure you want to reset %s to their default value? (YES/NO): ",
args[0]), false); err != nil {
return err
}
}
defaultConfig := &model.Config{}
defaultConfig.SetDefaults()
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
for _, arg := range args {
path := parseConfigPath(arg)
defaultValue, ok := getValue(path, *defaultConfig)
if !ok {
return errors.New("invalid key")
}
nErr := resetConfigValue(path, config, defaultValue)
if nErr != nil {
return nErr
}
}
newConfig, _, err := c.UpdateConfig(context.TODO(), config)
if err != nil {
return err
}
printer.PrintT("Value/s reset successfully", newConfig)
return nil
}
func configShowCmdF(c client.Client, _ *cobra.Command, _ []string) error {
printer.SetSingle(true)
printer.SetFormat(printer.FormatJSON)
config, _, err := c.GetConfig(context.TODO())
if err != nil {
return err
}
printer.Print(config)
return nil
}
func parseConfigPath(configPath string) []string {
return strings.Split(configPath, ".")
}
func configReloadCmdF(c client.Client, _ *cobra.Command, _ []string) error {
_, err := c.ReloadConfig(context.TODO())
if err != nil {
return err
}
return nil
}
func configMigrateCmdF(c client.Client, cmd *cobra.Command, args []string) error {
isLocal, _ := cmd.Flags().GetBool("local")
if !isLocal {
return errors.New("this command is only available in local mode. Please set the --local flag")
}
_, err := c.MigrateConfig(context.TODO(), args[0], args[1])
if err != nil {
return err
}
return nil
}
func configSubpathCmdF(cmd *cobra.Command, _ []string) error {
assetsDir, _ := cmd.Flags().GetString("assets-dir")
path, _ := cmd.Flags().GetString("path")
if err := utils.UpdateAssetsSubpathInDir(path, assetsDir); err != nil {
return errors.Wrap(err, "failed to update assets subpath")
}
printer.Print("Config subpath successfully modified")
return nil
}
func cloudRestricted(cfg any, path []string) bool {
return cloudRestrictedR(reflect.TypeOf(cfg), path)
}
// cloudRestricted checks if the config path is restricted to the cloud
func cloudRestrictedR(t reflect.Type, path []string) bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return false
}
for field := range t.Fields() {
if len(path) == 0 || field.Name != path[0] {
continue
}
accessTag := field.Tag.Get(model.ConfigAccessTagType)
if strings.Contains(accessTag, model.ConfigAccessTagCloudRestrictable) {
return true
}
return cloudRestrictedR(field.Type, path[1:])
}
return false
}
func configExportCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
removeDefaults, _ := cmd.Flags().GetBool("remove-defaults")
removeMasked, _ := cmd.Flags().GetBool("remove-masked")
config, _, err := c.GetConfigWithOptions(context.TODO(), model.GetConfigOptions{
RemoveDefaults: removeDefaults,
RemoveMasked: removeMasked,
})
if err != nil {
return err
}
printer.SetSingle(true)
printer.SetFormat(printer.FormatJSON)
printer.Print(config)
return nil
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestConfigResetCmdE2E() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("System admin and local reset", func(c client.Client) {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.ShowEmailAddress = false })
resetCmd := &cobra.Command{}
resetCmd.Flags().Bool("confirm", true, "")
err := configResetCmdF(c, resetCmd, []string{"PrivacySettings"})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
config := s.th.App.Config()
s.Require().True(*config.PrivacySettings.ShowEmailAddress)
})
s.Run("Reset for user without permission", func() {
printer.Clean()
resetCmd := &cobra.Command{}
args := []string{"PrivacySettings"}
resetCmd.Flags().Bool("confirm", true, "")
err := configResetCmdF(s.th.Client, resetCmd, args)
s.Require().NotNil(err)
s.Assert().Errorf(err, "You do not have the appropriate permissions.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigPatchCmd() {
s.SetupTestHelper().InitBasic(s.T())
tmpFile, err := os.CreateTemp(os.TempDir(), "config_*.json")
s.Require().Nil(err)
invalidFile, err := os.CreateTemp(os.TempDir(), "invalid_config_*.json")
s.Require().Nil(err)
_, err = tmpFile.WriteString(configFilePayload)
s.Require().Nil(err)
defer func() {
os.Remove(tmpFile.Name())
os.Remove(invalidFile.Name())
}()
s.RunForSystemAdminAndLocal("MM-T4051 - System admin and local patch", func(c client.Client) {
printer.Clean()
err := configPatchCmdF(c, &cobra.Command{}, []string{tmpFile.Name()})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T4052 - System admin and local patch with invalid file", func(c client.Client) {
printer.Clean()
err := configPatchCmdF(c, &cobra.Command{}, []string{invalidFile.Name()})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("MM-T4053 - Patch config for user without permission", func() {
printer.Clean()
err := configPatchCmdF(s.th.Client, &cobra.Command{}, []string{tmpFile.Name()})
s.Require().NotNil(err)
s.Assert().Errorf(err, "You do not have the appropriate permissions.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigGetCmdF() {
s.SetupTestHelper().InitBasic(s.T())
var driver string
if d := s.th.App.Config().SqlSettings.DriverName; d != nil {
driver = *d
}
s.RunForSystemAdminAndLocal("Get config value for a given key", func(c client.Client) {
printer.Clean()
args := []string{"SqlSettings.DriverName"}
err := configGetCmdF(c, &cobra.Command{}, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(driver, *(printer.GetLines()[0].(*string)))
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Expect error when using a nonexistent key", func(c client.Client) {
printer.Clean()
args := []string{"NonExistent.Key"}
err := configGetCmdF(c, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Get config value for a given key without permissions", func() {
printer.Clean()
args := []string{"SqlSettings.DriverName"}
err := configGetCmdF(s.th.Client, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigSetCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Set config value for a given key", func(c client.Client) {
printer.Clean()
args := []string{"SqlSettings.DriverName", "postgres"}
err := configSetCmdF(c, &cobra.Command{}, args)
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
config, ok := printer.GetLines()[0].(*model.Config)
s.Require().True(ok)
s.Require().Equal("postgres", *(config.SqlSettings.DriverName))
})
s.RunForSystemAdminAndLocal("Get error if the key doesn't exist", func(c client.Client) {
printer.Clean()
args := []string{"SqlSettings.WrongKey", "postgres"}
err := configSetCmdF(c, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Set config value for a given key without permissions", func() {
printer.Clean()
args := []string{"SqlSettings.DriverName", "postgres"}
err := configSetCmdF(s.th.Client, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("ImportSettings.Directory cannot be set via API but can via local mode", func() {
printer.Clean()
originalDir := *s.th.App.Config().ImportSettings.Directory
args := []string{"ImportSettings.Directory", "./api-blocked-import"}
err := configSetCmdF(s.th.SystemAdminClient, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "not allowed due to security reasons")
s.Require().Len(printer.GetLines(), 0)
// Verify value didn't change
s.Require().Equal(originalDir, *s.th.App.Config().ImportSettings.Directory)
printer.Clean()
args = []string{"ImportSettings.Directory", "./local-allowed-import"}
err = configSetCmdF(s.th.LocalClient, &cobra.Command{}, args)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
config, ok := printer.GetLines()[0].(*model.Config)
s.Require().True(ok)
s.Require().Equal("./local-allowed-import", *config.ImportSettings.Directory)
// Reset to original
s.th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ImportSettings.Directory = &originalDir
})
})
}
func (s *MmctlE2ETestSuite) TestConfigEditCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Edit a key in config", func(c client.Client) {
printer.Clean()
// ensure the value before editing
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableSVGs = false })
// create a shell script to edit config
content := `#!/bin/bash
sed -i'old' 's/\"EnableSVGs\": false/\"EnableSVGs\": true/' $1
rm $1'old'`
file, err := os.CreateTemp(os.TempDir(), "config_edit_*.sh")
s.Require().Nil(err)
defer func() {
os.Remove(file.Name())
}()
_, err = file.WriteString(content)
s.Require().Nil(err)
s.Require().Nil(file.Close())
s.Require().Nil(os.Chmod(file.Name(), 0700))
s.T().Setenv("EDITOR", file.Name())
// check the value after editing
err = configEditCmdF(c, nil, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
config := s.th.App.Config()
s.Require().True(*config.ServiceSettings.EnableSVGs)
})
s.Run("Edit config value without permissions", func() {
printer.Clean()
err := configEditCmdF(s.th.Client, nil, nil)
s.Require().NotNil(err)
s.Require().Error(err, "You do not have the appropriate permissions.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigShowCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Show server configs", func(c client.Client) {
printer.Clean()
err := configShowCmdF(c, nil, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Show server configs without permissions", func() {
printer.Clean()
err := configShowCmdF(s.th.Client, nil, nil)
s.Require().NotNil(err)
s.Require().Error(err, "You do not have the appropriate permissions")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigReloadCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Reload server config", func(c client.Client) {
printer.Clean()
err := configReloadCmdF(c, nil, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Reload server config without permissions", func() {
printer.Clean()
err := configReloadCmdF(s.th.Client, nil, nil)
s.Require().NotNil(err)
s.Require().Error(err, "You do not have the appropriate permissions")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigExportCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Get config normally", func(c client.Client) {
printer.Clean()
err := configExportCmdF(c, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
m, ok := printer.GetLines()[0].(map[string]any)
s.Require().True(ok)
if c == s.th.LocalClient {
// filter config is used to convert the config to a map[string]any
// local client has unrestricted access to the config
expectedConfig, err2 := model.FilterConfig(s.th.App.Config(), model.ConfigFilterOptions{GetConfigOptions: model.GetConfigOptions{}})
s.Require().NoError(err2)
s.Require().Equal(expectedConfig, m)
} else {
// filter config is used to convert the config to a map[string]any
// system admin client has restricted access to the config
expectedConfig, err2 := model.FilterConfig(s.th.App.GetSanitizedConfig(), model.ConfigFilterOptions{GetConfigOptions: model.GetConfigOptions{}})
s.Require().NoError(err2)
s.Require().Equal(expectedConfig, m)
}
})
s.Run("Should remove masked values for system admin client", func() {
printer.Clean()
exportCmd := &cobra.Command{}
exportCmd.Flags().Bool("remove-masked", true, "")
err := configExportCmdF(s.th.SystemAdminClient, exportCmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
m, ok := printer.GetLines()[0].(map[string]any)
s.Require().True(ok)
ss, ok := m["SqlSettings"].(map[string]any)
s.Require().True(ok)
_, ok = ss["DataSource"]
s.Require().False(ok)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Should retrieve configuration as-is with local client", func() {
printer.Clean()
exportCmd := &cobra.Command{}
err := configExportCmdF(s.th.LocalClient, exportCmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
m, ok := printer.GetLines()[0].(map[string]any)
s.Require().True(ok)
ss, ok := m["SqlSettings"].(map[string]any)
s.Require().True(ok)
ds, ok := ss["DataSource"]
s.Require().True(ok)
cfg := s.th.App.Config()
s.Require().Equal(*cfg.SqlSettings.DataSource, ds)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Should remove default values", func(c client.Client) {
printer.Clean()
exportCmd := &cobra.Command{}
exportCmd.Flags().Bool("remove-defaults", true, "")
err := configExportCmdF(c, exportCmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
m, ok := printer.GetLines()[0].(map[string]any)
s.Require().True(ok)
ss, ok := m["TeamSettings"].(map[string]any)
s.Require().True(ok)
_, ok = ss["MaxUsersPerTeam"] // it's not being changed by the test suite
s.Require().False(ok)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Get config value for a given key without permissions", func() {
printer.Clean()
err := configExportCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestConfigMigrateCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Should fail without the --local flag", func() {
printer.Clean()
args := []string{"config.json", "output.json"}
err := configMigrateCmdF(s.th.Client, &cobra.Command{}, args)
s.Require().Error(err)
s.Require().Equal("this command is only available in local mode. Please set the --local flag", err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Should be able to migrate config to file", func() {
printer.Clean()
args := []string{"config.json", "output.json"}
cmd := &cobra.Command{}
cmd.Flags().Bool("local", true, "")
err := configMigrateCmdF(s.th.LocalClient, cmd, args)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Should be able to migrate config to database", func() {
printer.Clean()
// Get the current database DSN from the test configuration
currentDSN := *s.th.App.Config().SqlSettings.DataSource
args := []string{"config.json", currentDSN}
cmd := &cobra.Command{}
cmd.Flags().Bool("local", true, "")
err := configMigrateCmdF(s.th.LocalClient, cmd, args)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Should fail on error when migrating config", func() {
printer.Clean()
args := []string{"from", "to"}
cmd := &cobra.Command{}
cmd.Flags().Bool("local", true, "")
err := configMigrateCmdF(s.th.LocalClient, cmd, args)
s.Require().Error(err)
s.Require().Equal("Failed to migrate config store.", err.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
var DocsCmd = &cobra.Command{
Use: "docs",
Short: "Generates mmctl documentation",
Args: cobra.NoArgs,
RunE: docsCmdF,
}
func init() {
DocsCmd.Flags().StringP("directory", "d", "docs", "The directory where the docs would be generated in.")
RootCmd.AddCommand(DocsCmd)
}
func docsCmdF(cmd *cobra.Command, args []string) error {
outDir, _ := cmd.Flags().GetString("directory")
fileInfo, err := os.Stat(outDir)
if err != nil {
if !os.IsNotExist(err) {
return err
}
if createErr := os.Mkdir(outDir, 0755); createErr != nil {
return createErr
}
} else if !fileInfo.IsDir() {
return fmt.Errorf("File %q is not a directory", outDir)
}
err = doc.GenReSTTree(RootCmd, outDir)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build enterprise
// This file is needed to ensure the enterprise code get complied
// when running tests. See https://mattermost.atlassian.net/browse/MM-54929
// for more details.
package commands
import (
// Enterprise Deps
_ "github.com/elastic/go-elasticsearch/v8"
_ "github.com/gorilla/handlers"
_ "github.com/hako/durafmt"
_ "github.com/hashicorp/memberlist"
_ "github.com/mattermost/gosaml2"
_ "github.com/mattermost/ldap"
_ "github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
_ "github.com/mattermost/mattermost/server/v8/enterprise"
_ "github.com/mattermost/rsc/qr"
_ "github.com/prometheus/client_golang/prometheus"
_ "github.com/prometheus/client_golang/prometheus/collectors"
_ "github.com/prometheus/client_golang/prometheus/promhttp"
_ "github.com/tylerb/graceful"
)

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// ErrEntityNotFound is thrown when an entity (user, team, etc.)
// is not found, returning the id sent by arguments
type ErrEntityNotFound struct {
Type string
ID string
}
func (e ErrEntityNotFound) Error() string {
return fmt.Sprintf("%s %s not found", e.Type, e.ID)
}
type NotFoundError struct {
Msg string
}
func (e *NotFoundError) Error() string {
return e.Msg
}
type BadRequestError struct {
Msg string
}
func (e *BadRequestError) Error() string {
return e.Msg
}
// ExtractErrorFromResponse extracts the error from the response,
// encapsulating it if matches the common cases, such as when it's
// not found, and when we've made a bad request
func ExtractErrorFromResponse(r *model.Response, err error) error {
switch r.StatusCode {
case http.StatusNotFound:
return &NotFoundError{Msg: err.Error()}
case http.StatusBadRequest:
return &BadRequestError{Msg: err.Error()}
default:
return err
}
}

View File

@@ -0,0 +1,318 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"io"
"os"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
var ExportCmd = &cobra.Command{
Use: "export",
Short: "Management of exports",
}
var ExportCreateCmd = &cobra.Command{
Use: "create",
Short: "Create export file",
Args: cobra.NoArgs,
RunE: withClient(exportCreateCmdF),
}
var ExportDownloadCmd = &cobra.Command{
Use: "download [exportname] [filepath]",
Short: "Download export files",
Example: ` # you can indicate the name of the export and its destination path
$ mmctl export download samplename sample_export.zip
# or if you only indicate the name, the path would match it
$ mmctl export download sample_export.zip`,
Args: cobra.MinimumNArgs(1),
RunE: withClient(exportDownloadCmdF),
}
var ExportGeneratePresignedURLCmd = &cobra.Command{
Use: "generate-presigned-url [exportname]",
Short: "Generate a presigned url for an export file. This is helpful when an export is big and might have trouble downloading from the Mattermost server.",
Args: cobra.ExactArgs(1),
RunE: withClient(exportGeneratePresignedURLCmdF),
}
var ExportDeleteCmd = &cobra.Command{
Use: "delete [exportname]",
Aliases: []string{"rm"},
Example: " export delete export_file.zip",
Short: "Delete export file",
Args: cobra.ExactArgs(1),
RunE: withClient(exportDeleteCmdF),
}
var ExportListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List export files",
Args: cobra.NoArgs,
RunE: withClient(exportListCmdF),
}
var ExportJobCmd = &cobra.Command{
Use: "job",
Short: "List, show and cancel export jobs",
}
var ExportJobListCmd = &cobra.Command{
Use: "list",
Example: " export job list",
Short: "List export jobs",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: withClient(exportJobListCmdF),
}
var ExportJobShowCmd = &cobra.Command{
Use: "show [exportJobID]",
Example: " export job show o98rj3ur83dp5dppfyk5yk6osy",
Short: "Show export job",
Args: cobra.ExactArgs(1),
RunE: withClient(exportJobShowCmdF),
}
var ExportJobCancelCmd = &cobra.Command{
Use: "cancel [exportJobID]",
Example: " export job cancel o98rj3ur83dp5dppfyk5yk6osy",
Short: "Cancel export job",
Args: cobra.ExactArgs(1),
RunE: withClient(exportJobCancelCmdF),
}
func init() {
ExportCreateCmd.Flags().Bool("no-attachments", false, "Exclude file attachments from the export file.")
ExportCreateCmd.Flags().Bool("include-archived-channels", false, "Include archived channels in the export file.")
ExportCreateCmd.Flags().Bool("include-profile-pictures", false, "Include profile pictures in the export file.")
ExportCreateCmd.Flags().Bool("no-roles-and-schemes", false, "Exclude roles and custom permission schemes from the export file.")
ExportDownloadCmd.Flags().Int("num-retries", 5, "Number of retries to do to resume a download.")
ExportJobListCmd.Flags().Int("page", 0, "Page number to fetch for the list of export jobs")
ExportJobListCmd.Flags().Int("per-page", DefaultPageSize, "Number of export jobs to be fetched")
ExportJobListCmd.Flags().Bool("all", false, "Fetch all export jobs. --page flag will be ignore if provided")
ExportJobCmd.AddCommand(
ExportJobListCmd,
ExportJobShowCmd,
ExportJobCancelCmd,
)
ExportCmd.AddCommand(
ExportCreateCmd,
ExportListCmd,
ExportDeleteCmd,
ExportDownloadCmd,
ExportGeneratePresignedURLCmd,
ExportJobCmd,
)
RootCmd.AddCommand(ExportCmd)
}
func exportCreateCmdF(c client.Client, command *cobra.Command, args []string) error {
data := make(map[string]string)
excludeAttachments, _ := command.Flags().GetBool("no-attachments")
if !excludeAttachments {
data["include_attachments"] = "true"
}
excludeRolesAndSchemes, _ := command.Flags().GetBool("no-roles-and-schemes")
if !excludeRolesAndSchemes {
data["include_roles_and_schemes"] = "true"
}
includeArchivedChannels, _ := command.Flags().GetBool("include-archived-channels")
if includeArchivedChannels {
data["include_archived_channels"] = "true"
}
includeProfilePictures, _ := command.Flags().GetBool("include-profile-pictures")
if includeProfilePictures {
data["include_profile_pictures"] = "true"
}
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: model.JobTypeExportProcess,
Data: data,
})
if err != nil {
return fmt.Errorf("failed to create export process job: %w", err)
}
printer.PrintT("Export process job successfully created, ID: {{.Id}}", job)
return nil
}
func exportListCmdF(c client.Client, command *cobra.Command, args []string) error {
exports, _, err := c.ListExports(context.TODO())
if err != nil {
return fmt.Errorf("failed to list exports: %w", err)
}
if len(exports) == 0 {
printer.Print("No export files found")
return nil
}
for _, name := range exports {
printer.Print(name)
}
return nil
}
func exportDeleteCmdF(c client.Client, command *cobra.Command, args []string) error {
name := args[0]
if _, err := c.DeleteExport(context.TODO(), name); err != nil {
return fmt.Errorf("failed to delete export: %w", err)
}
printer.Print(fmt.Sprintf("Export file %q has been deleted", name))
return nil
}
func exportGeneratePresignedURLCmdF(c client.Client, command *cobra.Command, args []string) error {
name := args[0]
presignedURL, _, err := c.GeneratePresignedURL(context.TODO(), name)
if err != nil {
return fmt.Errorf("failed to generate export link: %w", err)
}
printer.PrintT("Export link: {{.Link}}\nExpiration: {{.Expiration}}", map[string]any{
"Link": presignedURL.URL,
"Expiration": presignedURL.Expiration.String(),
})
return nil
}
func exportDownloadCmdF(c client.Client, command *cobra.Command, args []string) error {
var path string
name := args[0]
if len(args) > 1 {
path = args[1]
}
if path == "" {
path = name
}
retries, _ := command.Flags().GetInt("num-retries")
downloadFn := func(outFile *os.File) (string, error) {
off, err := outFile.Seek(0, io.SeekEnd)
if err != nil {
return "", fmt.Errorf("failed to seek file: %w", err)
}
_, _, err = c.DownloadExport(context.TODO(), name, outFile, off)
return "", err
}
_, err := downloadFile(path, downloadFn, retries, "export")
if err != nil {
return err
}
printer.Print(fmt.Sprintf("Export file downloaded to %q", path))
return nil
}
// downloadFile handles the common logic for downloading files in export and compliance-export commands
func downloadFile(path string, downloadFn func(*os.File) (string, error), retries int, fileType string) (string, error) {
var outFile *os.File
var createdFile bool
info, err := os.Stat(path)
switch {
case err != nil && !os.IsNotExist(err):
// some error occurred and not because file doesn't exist
return "", fmt.Errorf("failed to stat %s file: %w", fileType, err)
case err == nil && info.Size() > 0:
// we exit to avoid overwriting an existing non-empty file
return "", fmt.Errorf("%s file already exists", fileType)
case err != nil:
// file does not exist, we create it
outFile, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
createdFile = true
default:
// no error, file exists, we double check the permissions and then open it
permErr := os.Chmod(path, 0600)
if permErr != nil {
return "", fmt.Errorf("failed to change permissions on output file: %w", permErr)
}
outFile, err = os.OpenFile(path, os.O_WRONLY, 0600)
}
if err != nil {
return "", fmt.Errorf("failed to create/open %s file: %w", fileType, err)
}
defer outFile.Close()
var suggestedFilename string
for i := range retries + 1 { // need to include the first attempt
suggestedFilename, err = downloadFn(outFile)
if err != nil {
if i >= retries {
// Cleanup the file we created earlier
if createdFile {
rmErr := os.Remove(path)
if rmErr != nil {
printer.PrintError(fmt.Sprintf("Failed to cleanup tempory file: %s", rmErr))
}
}
return "", fmt.Errorf("failed to download %s after %d retries: %w", fileType, retries, err)
}
printer.PrintWarning(fmt.Sprintf("Download attempt %d/%d failed. Retrying...", i+1, retries+1))
continue
}
break
}
return suggestedFilename, nil
}
func exportJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
return jobListCmdF(c, command, model.JobTypeExportProcess, "")
}
func exportJobShowCmdF(c client.Client, command *cobra.Command, args []string) error {
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get export job: %w", err)
}
printJob(job)
return nil
}
func exportJobCancelCmdF(c client.Client, _ *cobra.Command, args []string) error {
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get export job: %w", err)
}
if _, err := c.CancelJob(context.TODO(), job.Id); err != nil {
return fmt.Errorf("failed to cancel export job: %w", err)
}
return nil
}

View File

@@ -0,0 +1,478 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/mattermost/mattermost/server/v8"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestExportListCmdF() {
s.SetupTestHelper()
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
exportPath, err := filepath.Abs(filepath.Join(*s.th.App.Config().FileSettings.Directory,
*s.th.App.Config().ExportSettings.Directory))
s.Require().Nil(err)
s.Run("MM-T3914 - no permissions", func() {
printer.Clean()
err := exportListCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().EqualError(err, "failed to list exports: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3913 - no exports", func(c client.Client) {
printer.Clean()
err := exportListCmdF(c, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No export files found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("MM-T3912 - some exports", func(c client.Client) {
cmd := &cobra.Command{}
numExports := 3
for i := range numExports {
exportName := fmt.Sprintf("export_%d.zip", i)
err := utils.CopyFile(importFilePath, filepath.Join(exportPath, exportName))
s.Require().Nil(err)
}
printer.Clean()
exports, appErr := s.th.App.ListExports()
s.Require().Nil(appErr)
err := exportListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), len(exports))
for i, name := range printer.GetLines() {
s.Require().Equal(exports[i], name.(string))
}
})
}
func (s *MmctlE2ETestSuite) TestExportDeleteCmdF() {
s.SetupTestHelper()
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
exportPath, err := filepath.Abs(filepath.Join(*s.th.App.Config().FileSettings.Directory,
*s.th.App.Config().ExportSettings.Directory))
s.Require().Nil(err)
exportName := "export.zip"
s.Run("MM-T3876 - no permissions", func() {
printer.Clean()
err := exportDeleteCmdF(s.th.Client, &cobra.Command{}, []string{exportName})
s.Require().EqualError(err, "failed to delete export: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3843 - delete export", func(c client.Client) {
cmd := &cobra.Command{}
err := utils.CopyFile(importFilePath, filepath.Join(exportPath, exportName))
s.Require().Nil(err)
printer.Clean()
exports, appErr := s.th.App.ListExports()
s.Require().Nil(appErr)
s.Require().NotEmpty(exports)
s.Require().Equal(exportName, exports[0])
err = exportDeleteCmdF(c, cmd, []string{exportName})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Equal(fmt.Sprintf(`Export file "%s" has been deleted`, exportName), printer.GetLines()[0])
exports, appErr = s.th.App.ListExports()
s.Require().Nil(appErr)
s.Require().Empty(exports)
printer.Clean()
// idempotence check
err = exportDeleteCmdF(c, cmd, []string{exportName})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Equal(fmt.Sprintf(`Export file "%s" has been deleted`, exportName), printer.GetLines()[0])
})
}
func (s *MmctlE2ETestSuite) TestExportCreateCmdF() {
s.SetupTestHelper()
s.Run("MM-T3877 - no permissions", func() {
printer.Clean()
err := exportCreateCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().EqualError(err, "failed to create export process job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3839 - create export", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
err := exportCreateCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal("true", printer.GetLines()[0].(*model.Job).Data["include_attachments"])
s.Require().Equal("true", printer.GetLines()[0].(*model.Job).Data["include_roles_and_schemes"])
})
s.RunForSystemAdminAndLocal("MM-T3878 - create export without attachments", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("no-attachments", true, "")
err := exportCreateCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal("", printer.GetLines()[0].(*model.Job).Data["include_attachments"])
})
s.RunForSystemAdminAndLocal("create export without roles and schemes", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("no-roles-and-schemes", true, "")
err := exportCreateCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal("", printer.GetLines()[0].(*model.Job).Data["include_roles_and_schemes"])
})
}
func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
s.SetupTestHelper()
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
exportPath, err := filepath.Abs(filepath.Join(*s.th.App.Config().FileSettings.Directory,
*s.th.App.Config().ExportSettings.Directory))
s.Require().Nil(err)
exportName := "export.zip"
s.Run("MM-T3879 - no permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 5, "")
err := exportDownloadCmdF(s.th.Client, cmd, []string{exportName})
s.Require().EqualError(err, "failed to download export after 5 retries: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3880 - existing, non empty file", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 5, "")
downloadPath, err := filepath.Abs(exportName)
s.Require().Nil(err)
err = utils.CopyFile(importFilePath, downloadPath)
s.Require().Nil(err)
defer os.Remove(downloadPath)
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
s.Require().EqualError(err, "export file already exists")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3882 - export does not exist", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 5, "")
downloadPath, err := filepath.Abs(exportName)
s.Require().Nil(err)
defer os.Remove(downloadPath)
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
s.Require().EqualError(err, "failed to download export after 5 retries: Unable to find export file.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3883 - existing, empty file", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 5, "")
exportFilePath := filepath.Join(exportPath, exportName)
err := utils.CopyFile(importFilePath, exportFilePath)
s.Require().Nil(err)
defer os.Remove(exportFilePath)
downloadPath, err := filepath.Abs(exportName)
s.Require().Nil(err)
defer os.Remove(downloadPath)
f, err := os.Create(downloadPath)
s.Require().Nil(err)
defer f.Close()
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
s.Require().Empty(printer.GetErrorLines())
info, err := os.Stat(downloadPath)
s.Require().Nil(err)
s.Require().Equal(fs.FileMode(0600), info.Mode().Perm(), fmt.Sprintf("expected %o, got %o", fs.FileMode(0600), info.Mode().Perm()))
})
s.RunForSystemAdminAndLocal("MM-T3842 - full download", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("num-retries", 5, "")
exportFilePath := filepath.Join(exportPath, exportName)
err := utils.CopyFile(importFilePath, exportFilePath)
s.Require().Nil(err)
defer os.Remove(exportFilePath)
downloadPath, err := filepath.Abs(exportName)
s.Require().Nil(err)
defer os.Remove(downloadPath)
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
s.Require().Empty(printer.GetErrorLines())
expected, err := os.ReadFile(exportFilePath)
s.Require().Nil(err)
actual, err := os.ReadFile(downloadPath)
s.Require().Nil(err)
s.Require().Equal(expected, actual)
info, err := os.Stat(downloadPath)
s.Require().Nil(err)
s.Require().Equal(fs.FileMode(0600), info.Mode().Perm(), fmt.Sprintf("expected %o, got %o", fs.FileMode(0600), info.Mode().Perm()))
})
}
func (s *MmctlE2ETestSuite) TestExportJobShowCmdF() {
s.SetupTestHelper().InitBasic(s.T())
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
s.Run("MM-T3885 - no permissions", func() {
printer.Clean()
job1, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
err := exportJobShowCmdF(s.th.Client, &cobra.Command{}, []string{job1.Id})
s.Require().EqualError(err, "failed to get export job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3886 - not found", func(c client.Client) {
printer.Clean()
err := exportJobShowCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().ErrorContains(err, "failed to get export job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3841 - found", func(c client.Client) {
printer.Clean()
err := exportJobShowCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestExportJobListCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("MM-T3887 - no permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := exportJobListCmdF(s.th.Client, cmd, nil)
s.Require().EqualError(err, "failed to get jobs: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("MM-T3888 - no export jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := exportJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("MM-T3840 - some export jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
_, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job2, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job3, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
err := exportJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), perPage)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(job3, printer.GetLines()[0].(*model.Job))
s.Require().Equal(job2, printer.GetLines()[1].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestExportJobCancelCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Cancel an export job without permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
err := exportJobCancelCmdF(s.th.Client, cmd, []string{job.Id})
s.Require().EqualError(err, "failed to get export job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("No export jobs to cancel", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
err := exportJobCancelCmdF(c, cmd, []string{model.NewId()})
s.Require().ErrorContains(err, "failed to get export job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("Cancel an export job", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
job1, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job2, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
err := exportJobCancelCmdF(c, cmd, []string{job1.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
// Get job1 again to refresh its status
job1, appErr = s.th.App.GetJob(s.th.Context, job1.Id)
s.Require().Nil(appErr)
// Get job2 again to ensure its status did not change
job2, _ = s.th.App.GetJob(s.th.Context, job2.Id)
s.Require().Nil(appErr)
s.Require().Equal(job1.Status, model.JobStatusCanceled)
s.Require().NotEqual(job2.Status, model.JobStatusCanceled)
})
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestExportCreateCmdF() {
s.Run("create export", func() {
printer.Clean()
mockJob := &model.Job{
Type: model.JobTypeExportProcess,
Data: map[string]string{
"include_attachments": "true",
"include_roles_and_schemes": "true",
},
}
s.client.
EXPECT().
CreateJob(context.TODO(), mockJob).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := exportCreateCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("create export without attachments", func() {
printer.Clean()
mockJob := &model.Job{
Type: model.JobTypeExportProcess,
Data: map[string]string{
"include_roles_and_schemes": "true",
},
}
s.client.
EXPECT().
CreateJob(context.TODO(), mockJob).
Return(mockJob, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("no-attachments", true, "")
err := exportCreateCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("create export without roles and schemes", func() {
printer.Clean()
mockJob := &model.Job{
Type: model.JobTypeExportProcess,
Data: map[string]string{
"include_attachments": "true",
},
}
s.client.
EXPECT().
CreateJob(context.TODO(), mockJob).
Return(mockJob, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("no-roles-and-schemes", true, "")
err := exportCreateCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlUnitTestSuite) TestExportDeleteCmdF() {
printer.Clean()
exportName := "export.zip"
s.client.
EXPECT().
DeleteExport(context.TODO(), exportName).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := exportDeleteCmdF(s.client, &cobra.Command{}, []string{exportName})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal(fmt.Sprintf(`Export file "%s" has been deleted`, exportName), printer.GetLines()[0])
}
func (s *MmctlUnitTestSuite) TestExportListCmdF() {
s.Run("no exports", func() {
printer.Clean()
var mockExports []string
s.client.
EXPECT().
ListExports(context.TODO()).
Return(mockExports, &model.Response{}, nil).
Times(1)
err := exportListCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal("No export files found", printer.GetLines()[0])
})
s.Run("some exports", func() {
printer.Clean()
mockExports := []string{
"export1.zip",
"export2.zip",
"export3.zip",
}
s.client.
EXPECT().
ListExports(context.TODO()).
Return(mockExports, &model.Response{}, nil).
Times(1)
err := exportListCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockExports))
s.Len(printer.GetErrorLines(), 0)
for i, line := range printer.GetLines() {
s.Equal(mockExports[i], line)
}
})
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"strconv"
"time"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
var ExtractCmd = &cobra.Command{
Use: "extract",
Short: "Management of content extraction job.",
}
var ExtractRunCmd = &cobra.Command{
Use: "run",
Example: " extract run",
Short: "Start a content extraction job.",
Args: cobra.NoArgs,
RunE: withClient(extractRunCmdF),
}
var ExtractJobCmd = &cobra.Command{
Use: "job",
Short: "List and show content extraction jobs",
}
var ExtractJobListCmd = &cobra.Command{
Use: "list",
Example: " extract job list",
Short: "List content extraction jobs",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: withClient(extractJobListCmdF),
}
var ExtractJobShowCmd = &cobra.Command{
Use: "show [extractJobID]",
Example: " extract job show f3d68qkkm7n8xgsfxwuo498rah",
Short: "Show extract job",
Args: cobra.ExactArgs(1),
RunE: withClient(extractJobShowCmdF),
}
func init() {
ExtractRunCmd.Flags().Int64("from", 0, "The timestamp of the earliest file to extract, expressed in seconds since the unix epoch.")
ExtractRunCmd.Flags().Int64("to", 0, "The timestamp of the latest file to extract, expressed in seconds since the unix epoch. Defaults to the current time.")
ExtractJobListCmd.Flags().Int("page", 0, "Page number to fetch for the list of extract jobs")
ExtractJobListCmd.Flags().Int("per-page", DefaultPageSize, "Number of extract jobs to be fetched")
ExtractJobListCmd.Flags().Bool("all", false, "Fetch all extract jobs. --page flag will be ignore if provided")
ExtractJobCmd.AddCommand(
ExtractJobListCmd,
ExtractJobShowCmd,
)
ExtractCmd.AddCommand(
ExtractRunCmd,
ExtractJobCmd,
)
RootCmd.AddCommand(ExtractCmd)
}
func extractRunCmdF(c client.Client, command *cobra.Command, args []string) error {
from, err := command.Flags().GetInt64("from")
if err != nil {
return err
}
to, err := command.Flags().GetInt64("to")
if err != nil {
return err
}
if to == 0 {
to = model.GetMillis() / 1000
}
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{
"from": strconv.FormatInt(from, 10),
"to": strconv.FormatInt(to, 10),
},
})
if err != nil {
return fmt.Errorf("failed to create content extraction job: %w", err)
}
printer.PrintT("Content extraction job successfully created, ID: {{.Id}}", job)
return nil
}
func extractJobShowCmdF(c client.Client, command *cobra.Command, args []string) error {
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get content extraction job: %w", err)
}
printExtractContentJob(job)
return nil
}
func extractJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
return jobListCmdF(c, command, model.JobTypeExtractContent, "")
}
func printExtractContentJob(job *model.Job) {
if job.StartAt > 0 {
printer.PrintT(fmt.Sprintf(" ID: {{.Id}}\n Status: {{.Status}}\n Created: %s\n Started: %s\n Processed: %s\n Errors: %s\n",
time.Unix(job.CreateAt/1000, 0), time.Unix(job.StartAt/1000, 0), job.Data["processed"], job.Data["errors"]), job)
} else {
printer.PrintT(fmt.Sprintf(" ID: {{.Id}}\n Status: {{.Status}}\n Created: %s\n\n",
time.Unix(job.CreateAt/1000, 0)), job)
}
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"os"
"path/filepath"
"time"
"github.com/mattermost/mattermost/server/v8"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestExtractRunCmdF() {
s.SetupTestHelper().InitBasic(s.T())
docName := "sample-doc.pdf"
docFilePath := filepath.Join(server.GetPackagePath(), "tests", docName)
s.Run("no permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int64("from", 0, "")
cmd.Flags().Int64("to", model.GetMillis()/1000, "")
err := extractRunCmdF(s.th.Client, cmd, []string{})
s.Require().NotNil(err)
s.Require().Equal("failed to create content extraction job: You do not have the appropriate permissions.", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("run extraction job", func(c client.Client) {
printer.Clean()
file, err := os.Open(docFilePath)
s.Require().NoError(err)
defer file.Close()
info, err := file.Stat()
s.Require().NoError(err)
us, _, err := s.th.SystemAdminClient.CreateUpload(context.Background(), &model.UploadSession{
ChannelId: s.th.BasicChannel.Id,
Filename: info.Name(),
FileSize: info.Size(),
})
s.Require().NoError(err)
s.Require().NotNil(us)
_, _, err = s.th.SystemAdminClient.UploadData(context.Background(), us.Id, file)
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().Int64("from", 0, "")
cmd.Flags().Int64("to", model.GetMillis()/1000, "")
err = extractRunCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
})
}
func (s *MmctlE2ETestSuite) TestExtractJobShowCmdF() {
s.SetupTestHelper().InitBasic(s.T())
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{},
})
s.Require().Nil(appErr)
s.Run("no permissions", func() {
printer.Clean()
job1, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{},
})
s.Require().Nil(appErr)
err := extractJobShowCmdF(s.th.Client, &cobra.Command{}, []string{job1.Id})
s.Require().NotNil(err)
s.Require().Equal("failed to get content extraction job: You do not have the appropriate permissions.", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("not found", func(c client.Client) {
printer.Clean()
err := extractJobShowCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "failed to get content extraction job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("found", func(c client.Client) {
printer.Clean()
err := extractJobShowCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestExtractJobListCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("no permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := extractJobListCmdF(s.th.Client, cmd, nil)
s.Require().NotNil(err)
s.Require().Equal("failed to get jobs: You do not have the appropriate permissions.", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("no content extraction jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := extractJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("some content extraction jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
_, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{},
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job2, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{},
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job3, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExtractContent,
Data: map[string]string{},
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
err := extractJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), perPage)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(job3, printer.GetLines()[0].(*model.Job))
s.Require().Equal(job2, printer.GetLines()[1].(*model.Job))
})
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var GroupCmd = &cobra.Command{
Use: "group",
Short: "Management of groups",
}
var ListLdapGroupsCmd = &cobra.Command{
Use: "list-ldap",
Short: "List LDAP groups",
Example: " group list-ldap",
Args: cobra.NoArgs,
RunE: withClient(listLdapGroupsCmdF),
}
var ChannelGroupCmd = &cobra.Command{
Use: "channel",
Short: "Management of channel groups",
}
var ChannelGroupEnableCmd = &cobra.Command{
Use: "enable [team]:[channel]",
Short: "Enables group constrains in the specified channel",
Example: " group channel enable myteam:mychannel",
Args: cobra.ExactArgs(1),
RunE: withClient(channelGroupEnableCmdF),
}
var ChannelGroupDisableCmd = &cobra.Command{
Use: "disable [team]:[channel]",
Short: "Disables group constrains in the specified channel",
Example: " group channel disable myteam:mychannel",
Args: cobra.ExactArgs(1),
RunE: withClient(channelGroupDisableCmdF),
}
// ChannelGroupStatusCmd is a command which outputs group constrain status for a channel
var ChannelGroupStatusCmd = &cobra.Command{
Use: "status [team]:[channel]",
Short: "Show's the group constrain status for the specified channel",
Example: " group channel status myteam:mychannel",
Args: cobra.ExactArgs(1),
RunE: withClient(channelGroupStatusCmdF),
}
var ChannelGroupListCmd = &cobra.Command{
Use: "list [team]:[channel]",
Short: "List channel groups",
Long: "List the groups associated with a channel",
Example: " group channel list myteam:mychannel",
Args: cobra.ExactArgs(1),
RunE: withClient(channelGroupListCmdF),
}
var TeamGroupCmd = &cobra.Command{
Use: "team",
Short: "Management of team groups",
}
var TeamGroupEnableCmd = &cobra.Command{
Use: "enable [team]",
Short: "Enables group constrains in the specified team",
Example: " group team enable myteam",
Args: cobra.ExactArgs(1),
RunE: withClient(teamGroupEnableCmdF),
}
var TeamGroupDisableCmd = &cobra.Command{
Use: "disable [team]",
Short: "Disables group constrains in the specified team",
Example: " group team disable myteam",
Args: cobra.ExactArgs(1),
RunE: withClient(teamGroupDisableCmdF),
}
var TeamGroupStatusCmd = &cobra.Command{
Use: "status [team]",
Short: "Show's the group constrain status for the specified team",
Example: " group team status myteam",
Args: cobra.ExactArgs(1),
RunE: withClient(teamGroupStatusCmdF),
}
var TeamGroupListCmd = &cobra.Command{
Use: "list [team]",
Short: "List team groups",
Long: "List the groups associated with a team",
Example: " group team list myteam",
Args: cobra.ExactArgs(1),
RunE: withClient(teamGroupListCmdF),
}
var UserGroupCmd = &cobra.Command{
Use: "user",
Short: "Management of custom user groups",
}
var UserGroupRestoreCmd = &cobra.Command{
Use: "restore [groupname]",
Short: "Restore user group",
Long: "Restore deleted custom user group",
Example: " group user restore examplegroup",
Args: cobra.ExactArgs(1),
RunE: withClient(userGroupRestoreCmdF),
}
func init() {
ChannelGroupCmd.AddCommand(
ChannelGroupEnableCmd,
ChannelGroupDisableCmd,
ChannelGroupStatusCmd,
ChannelGroupListCmd,
)
TeamGroupCmd.AddCommand(
TeamGroupEnableCmd,
TeamGroupDisableCmd,
TeamGroupStatusCmd,
TeamGroupListCmd,
)
UserGroupCmd.AddCommand(
UserGroupRestoreCmd,
)
GroupCmd.AddCommand(
ListLdapGroupsCmd,
ChannelGroupCmd,
TeamGroupCmd,
UserGroupCmd,
)
RootCmd.AddCommand(GroupCmd)
}
func listLdapGroupsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
groups, _, err := c.GetLdapGroups(context.TODO())
if err != nil {
return err
}
for _, group := range groups {
printer.PrintT("{{.DisplayName}}", group)
}
return nil
}
func channelGroupEnableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
groupOpts := &model.GroupSearchOpts{
PageOpts: &model.PageOpts{
Page: 0,
PerPage: 10,
},
}
groups, _, _, err := c.GetGroupsByChannel(context.TODO(), channel.Id, *groupOpts)
if err != nil {
return err
}
if len(groups) == 0 {
return errors.New("Channel '" + args[0] + "' has no groups associated. It cannot be group-constrained")
}
channelPatch := model.ChannelPatch{GroupConstrained: new(true)}
if _, _, err = c.PatchChannel(context.TODO(), channel.Id, &channelPatch); err != nil {
return err
}
return nil
}
func channelGroupDisableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
channelPatch := model.ChannelPatch{GroupConstrained: new(false)}
if _, _, err := c.PatchChannel(context.TODO(), channel.Id, &channelPatch); err != nil {
return err
}
return nil
}
func channelGroupStatusCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
if channel.GroupConstrained != nil && *channel.GroupConstrained {
printer.Print("Enabled")
} else {
printer.Print("Disabled")
}
return nil
}
func channelGroupListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
groupOpts := model.GroupSearchOpts{
PageOpts: &model.PageOpts{
Page: 0,
PerPage: 9999,
},
}
groups, _, _, err := c.GetGroupsByChannel(context.TODO(), channel.Id, groupOpts)
if err != nil {
return err
}
for _, group := range groups {
printer.PrintT("{{.DisplayName}}", group)
}
return nil
}
func teamGroupEnableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return errors.New("Unable to find team '" + args[0] + "'")
}
groupOpts := model.GroupSearchOpts{
PageOpts: &model.PageOpts{
Page: 0,
PerPage: 10,
},
}
groups, _, _, err := c.GetGroupsByTeam(context.TODO(), team.Id, groupOpts)
if err != nil {
return err
}
if len(groups) == 0 {
return errors.New("Team '" + args[0] + "' has no groups associated. It cannot be group-constrained")
}
teamPatch := model.TeamPatch{GroupConstrained: new(true)}
if _, _, err = c.PatchTeam(context.TODO(), team.Id, &teamPatch); err != nil {
return err
}
return nil
}
func teamGroupDisableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return errors.New("Unable to find team '" + args[0] + "'")
}
teamPatch := model.TeamPatch{GroupConstrained: new(false)}
if _, _, err := c.PatchTeam(context.TODO(), team.Id, &teamPatch); err != nil {
return err
}
return nil
}
func teamGroupStatusCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return errors.New("Unable to find team '" + args[0] + "'")
}
if team.GroupConstrained != nil && *team.GroupConstrained {
printer.Print("Enabled")
} else {
printer.Print("Disabled")
}
return nil
}
func teamGroupListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
team := getTeamFromTeamArg(c, args[0])
if team == nil {
return errors.New("Unable to find team '" + args[0] + "'")
}
groupOpts := model.GroupSearchOpts{
PageOpts: &model.PageOpts{
Page: 0,
PerPage: 9999,
},
}
groups, _, _, err := c.GetGroupsByTeam(context.TODO(), team.Id, groupOpts)
if err != nil {
return err
}
for _, group := range groups {
printer.PrintT("{{.DisplayName}}", group)
}
return nil
}
func userGroupRestoreCmdF(c client.Client, cmd *cobra.Command, args []string) error {
groupID := args[0]
_, resp, err := c.RestoreGroup(context.TODO(), groupID, "")
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
printer.Print("Group successfully restored with ID: " + groupID)
}
return nil
}

View File

@@ -0,0 +1,557 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestChannelGroupEnableCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
defer func() {
err := s.th.App.DeleteChannel(s.th.Context, channel, "")
s.Require().Nil(err)
}()
id := model.NewId()
group, appErr := s.th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceLdap,
Description: "description_" + id,
RemoteId: new(model.NewId()),
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroup(group.Id)
s.Require().Nil(err)
}()
_, appErr = s.th.App.UpsertGroupSyncable(&model.GroupSyncable{
GroupId: group.Id,
SyncableId: channel.Id,
Type: model.GroupSyncableTypeChannel,
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroupSyncable(group.Id, channel.Id, model.GroupSyncableTypeChannel)
s.Require().Nil(err)
}()
s.Run("Should not allow regular user to enable group for channel", func() {
printer.Clean()
err := channelGroupEnableCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Should enable group sync for the channel", func(c client.Client) {
printer.Clean()
err := channelGroupEnableCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().NoError(err)
channel.GroupConstrained = new(false)
defer func() {
_, err := s.th.App.UpdateChannel(s.th.Context, channel)
s.Require().Nil(err)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
ch, appErr := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(appErr)
s.Require().True(ch.IsGroupConstrained())
})
}
func (s *MmctlE2ETestSuite) TestChannelGroupDisableCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
defer func() {
err := s.th.App.DeleteChannel(s.th.Context, channel, "")
s.Require().Nil(err)
}()
id := model.NewId()
group, appErr := s.th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceLdap,
Description: "description_" + id,
RemoteId: new(model.NewId()),
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroup(group.Id)
s.Require().Nil(err)
}()
_, appErr = s.th.App.UpsertGroupSyncable(&model.GroupSyncable{
GroupId: group.Id,
SyncableId: channel.Id,
Type: model.GroupSyncableTypeChannel,
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroupSyncable(group.Id, channel.Id, model.GroupSyncableTypeChannel)
s.Require().Nil(err)
}()
channel.GroupConstrained = new(true)
defer func() {
_, err := s.th.App.UpdateChannel(s.th.Context, channel)
s.Require().Nil(err)
}()
s.Run("Should not allow regular user to disable group for channel", func() {
printer.Clean()
err := channelGroupDisableCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Should disable group sync for the channel", func(c client.Client) {
printer.Clean()
err := channelGroupDisableCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().NoError(err)
channel.GroupConstrained = new(true)
defer func() {
_, err := s.th.App.UpdateChannel(s.th.Context, channel)
s.Require().Nil(err)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
ch, appErr := s.th.App.GetChannel(s.th.Context, channel.Id)
s.Require().Nil(appErr)
s.Require().False(ch.IsGroupConstrained())
})
}
func (s *MmctlE2ETestSuite) TestListLdapGroupsCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
configForLdap(s.th)
s.Run("MM-T3977 Should not allow regular user to list LDAP groups", func() {
printer.Clean()
err := listLdapGroupsCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3976 Should list LDAP groups", func(c client.Client) {
printer.Clean()
// we rely on the test data generated for the openldap server
// i.e. "test-data.ldif" script
err := listLdapGroupsCmdF(c, &cobra.Command{}, nil)
s.Require().NoError(err)
s.Require().NotEmpty(printer.GetLines())
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestChannelGroupStatusCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
GroupConstrained: new(true),
}, false)
s.Require().Nil(appErr)
defer func() {
err := s.th.App.DeleteChannel(s.th.Context, channel, "")
s.Require().Nil(err)
}()
channelName2 := api4.GenerateTestChannelName()
channel2, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName2,
DisplayName: "dn_" + channelName2,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
defer func() {
err := s.th.App.DeleteChannel(s.th.Context, channel2, "")
s.Require().Nil(err)
}()
s.RunForAllClients("MM-T3974 Should allow to get status of a group constrained channel", func(c client.Client) {
printer.Clean()
err := channelGroupStatusCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Enabled")
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForAllClients("MM-T3975 Should allow to get status of a regular channel", func(c client.Client) {
printer.Clean()
err := channelGroupStatusCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName2})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Disabled")
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestChannelGroupListCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
channelName := api4.GenerateTestChannelName()
channel, appErr := s.th.App.CreateChannel(s.th.Context, &model.Channel{
TeamId: s.th.BasicTeam.Id,
Name: channelName,
DisplayName: "dn_" + channelName,
Type: model.ChannelTypeOpen,
}, false)
s.Require().Nil(appErr)
defer func() {
err := s.th.App.DeleteChannel(s.th.Context, channel, "")
s.Require().Nil(err)
}()
id := model.NewId()
group, appErr := s.th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceLdap,
Description: "description_" + id,
RemoteId: new(model.NewId()),
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroup(group.Id)
s.Require().Nil(err)
}()
_, appErr = s.th.App.UpsertGroupSyncable(&model.GroupSyncable{
GroupId: group.Id,
SyncableId: channel.Id,
Type: model.GroupSyncableTypeChannel,
})
s.Require().Nil(appErr)
defer func() {
_, err := s.th.App.DeleteGroupSyncable(group.Id, channel.Id, model.GroupSyncableTypeChannel)
s.Require().Nil(err)
}()
s.Run("MM-T3970 Should not allow regular user to get list of LDAP groups in a channel", func() {
printer.Clean()
err := channelGroupListCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3969 Should allow to get list of LDAP groups in a channel", func(c client.Client) {
printer.Clean()
err := channelGroupListCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name + ":" + channelName})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
gs, ok := printer.GetLines()[0].(*model.GroupWithSchemeAdmin)
s.Require().True(ok)
s.Require().Equal(gs.Group, *group)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestTeamGroupDisableCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
team, _, cleanUpFn := createTestGroupTeam(s)
defer cleanUpFn()
team.GroupConstrained = new(true)
_, err := s.th.App.UpdateTeam(team)
s.Require().Nil(err)
s.Run("MM-T3919 Should not allow regular user to disable group for team", func() {
printer.Clean()
err := teamGroupDisableCmdF(s.th.Client, &cobra.Command{}, []string{team.Name})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3920 Should disable group sync for the team", func(c client.Client) {
printer.Clean()
err := teamGroupDisableCmdF(c, &cobra.Command{}, []string{team.Name})
s.Require().NoError(err)
team.GroupConstrained = new(true)
defer func() {
_, err := s.th.App.UpdateTeam(team)
s.Require().Nil(err)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
tm, appErr := s.th.App.GetTeam(team.Id)
s.Require().Nil(appErr)
s.Require().False(tm.IsGroupConstrained())
})
}
func (s *MmctlE2ETestSuite) TestTeamGroupEnableCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
team, _, cleanUpFn := createTestGroupTeam(s)
defer cleanUpFn()
s.Run("MM-T3917 Should not allow regular user to enable group for team", func() {
printer.Clean()
err := teamGroupEnableCmdF(s.th.Client, &cobra.Command{}, []string{team.Name})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3918 Should enable group sync for the team", func(c client.Client) {
printer.Clean()
err := teamGroupEnableCmdF(c, &cobra.Command{}, []string{team.Name})
s.Require().NoError(err)
team.GroupConstrained = new(false)
defer func() {
_, err := s.th.App.UpdateTeam(team)
s.Require().Nil(err)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
tm, appErr := s.th.App.GetTeam(team.Id)
s.Require().Nil(appErr)
s.Require().True(tm.IsGroupConstrained())
})
}
func (s *MmctlE2ETestSuite) TestTeamGroupStatusCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
team, _, cleanUpFn := createTestGroupTeam(s)
defer func() {
cleanUpFn()
}()
teamName2 := api4.GenerateTestTeamName()
team2, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: teamName2,
DisplayName: "dn_" + teamName2,
Type: model.TeamInvite,
})
s.Require().Nil(appErr)
defer func() {
err := s.th.App.PermanentDeleteTeam(s.th.Context, team2)
s.Require().Nil(err)
}()
s.Run("MM-T3921 Should not allow regular user to get status of LDAP groups in a team where they are not a member of", func() {
printer.Clean()
err := teamGroupStatusCmdF(s.th.Client, &cobra.Command{}, []string{team.Name})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, team.Id, s.th.BasicUser.Id, s.th.SystemAdminUser.Id)
s.Require().Nil(appErr)
_, _, appErr = s.th.App.AddUserToTeam(s.th.Context, team2.Id, s.th.BasicUser.Id, s.th.SystemAdminUser.Id)
s.Require().Nil(appErr)
s.RunForAllClients("MM-T3922 Should allow to get status of a group constrained team", func(c client.Client) {
printer.Clean()
err := teamGroupStatusCmdF(c, &cobra.Command{}, []string{team.Name})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Enabled")
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForAllClients("MM-T3923 Should allow to get status of a regular team", func(c client.Client) {
printer.Clean()
err := teamGroupStatusCmdF(c, &cobra.Command{}, []string{teamName2})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Disabled")
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestTeamGroupListCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
team, group, cleanUpFn := createTestGroupTeam(s)
defer func() {
cleanUpFn()
}()
s.Run("MM-T3924 Should not allow regular user to get list of LDAP groups in a team", func() {
printer.Clean()
err := teamGroupListCmdF(s.th.Client, &cobra.Command{}, []string{team.Name})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3925 Should allow to get list of LDAP groups in a team", func(c client.Client) {
printer.Clean()
err := teamGroupListCmdF(c, &cobra.Command{}, []string{team.Name})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
gs, ok := printer.GetLines()[0].(*model.GroupWithSchemeAdmin)
s.Require().True(ok)
s.Require().Equal(gs.Group, *group)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func createTestGroupTeam(s *MmctlE2ETestSuite) (*model.Team, *model.Group, func()) {
teamName := api4.GenerateTestTeamName()
team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{
Name: teamName,
DisplayName: "dn_" + teamName,
Type: model.TeamOpen,
GroupConstrained: new(true),
})
s.Require().Nil(appErr)
id := model.NewId()
group, appErr := s.th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceLdap,
Description: "description_" + id,
RemoteId: new(model.NewId()),
})
s.Require().Nil(appErr)
_, appErr = s.th.App.UpsertGroupSyncable(&model.GroupSyncable{
GroupId: group.Id,
SyncableId: team.Id,
Type: model.GroupSyncableTypeTeam,
})
s.Require().Nil(appErr)
cleanUpFn := func() {
_, err := s.th.App.DeleteGroupSyncable(group.Id, team.Id, model.GroupSyncableTypeTeam)
s.Require().Nil(err)
_, err = s.th.App.DeleteGroup(group.Id)
s.Require().Nil(err)
err = s.th.App.PermanentDeleteTeamId(s.th.Context, team.Id)
s.Require().Nil(err)
}
return team, group, cleanUpFn
}
func (s *MmctlE2ETestSuite) TestUserGroupRestoreCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
// create group
id := model.NewId()
group, appErr := s.th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceCustom,
Description: "description_" + id,
RemoteId: new(model.NewId()),
})
s.Require().Nil(appErr)
s.th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional, "ldap"))
defer func() {
_, err := s.th.App.DeleteGroup(group.Id)
s.Require().Nil(err)
}()
s.Run("Should allow group restore after deletion", func() {
printer.Clean()
_, appErr := s.th.App.DeleteGroup(group.Id)
s.Require().Nil(appErr)
s.th.RemovePermissionFromRole(s.T(), model.PermissionRestoreCustomGroup.Id, model.SystemUserRoleId)
err := userGroupRestoreCmdF(s.th.Client, &cobra.Command{}, []string{group.Id})
s.Require().NotNil(err)
s.Require().Equal(err.Error(), "You do not have the appropriate permissions.")
s.th.AddPermissionToRole(s.T(), model.PermissionRestoreCustomGroup.Id, model.SystemUserRoleId)
err = userGroupRestoreCmdF(s.th.Client, &cobra.Command{}, []string{group.Id})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0].(string), "Group successfully restored with ID: "+group.Id)
s.Require().Len(printer.GetErrorLines(), 0)
// shouldn't allow restoring of active groups
printer.Clean()
err = userGroupRestoreCmdF(s.th.Client, &cobra.Command{}, []string{group.Id})
s.Require().NotNil(err)
s.Require().Equal(err.Error(), "no matching group found")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,578 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"text/template"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/commands/importer"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Management of imports",
}
var ImportUploadCmd = &cobra.Command{
Use: "upload [filepath]",
Short: "Upload import files",
Example: " import upload import_file.zip",
Args: cobra.ExactArgs(1),
RunE: withClient(importUploadCmdF),
}
var ImportDeleteCmd = &cobra.Command{
Use: "delete [importname]",
Short: "Delete an import file",
Example: " import delete import_file.zip",
Args: cobra.ExactArgs(1),
RunE: withClient(importDeleteCmdF),
}
var ImportListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List import files",
Example: " import list",
}
var ImportListAvailableCmd = &cobra.Command{
Use: "available",
Short: "List available import files",
Example: " import list available",
Args: cobra.NoArgs,
RunE: withClient(importListAvailableCmdF),
}
var ImportJobCmd = &cobra.Command{
Use: "job",
Short: "List and show import jobs",
}
var ImportListIncompleteCmd = &cobra.Command{
Use: "incomplete",
Short: "List incomplete import files uploads",
Example: " import list incomplete",
Args: cobra.NoArgs,
RunE: withClient(importListIncompleteCmdF),
}
var ImportJobListCmd = &cobra.Command{
Use: "list",
Example: " import job list",
Short: "List import jobs",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: withClient(importJobListCmdF),
}
var ImportJobShowCmd = &cobra.Command{
Use: "show [importJobID]",
Example: " import job show f3d68qkkm7n8xgsfxwuo498rah",
Short: "Show import job",
Args: cobra.ExactArgs(1),
RunE: withClient(importJobShowCmdF),
}
var ImportProcessCmd = &cobra.Command{
Use: "process [importname]",
Example: " import process 35uy6cwrqfnhdx3genrhqqznxc_import.zip",
Short: "Start an import job",
Args: cobra.ExactArgs(1),
RunE: withClient(importProcessCmdF),
}
var ImportValidateCmd = &cobra.Command{
Use: "validate [filepath]",
Example: " import validate import_file.zip --team myteam --team myotherteam",
Short: "Validate an import file",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
return importValidateCmdF(nil, command, args)
},
}
func init() {
ImportUploadCmd.Flags().Bool("resume", false, "Set to true to resume an incomplete import upload.")
ImportUploadCmd.Flags().String("upload", "", "The ID of the import upload to resume.")
ImportJobListCmd.Flags().Int("page", 0, "Page number to fetch for the list of import jobs")
ImportJobListCmd.Flags().Int("per-page", DefaultPageSize, "Number of import jobs to be fetched")
ImportJobListCmd.Flags().Bool("all", false, "Fetch all import jobs. --page flag will be ignore if provided")
ImportValidateCmd.Flags().StringArray("team", nil, "Predefined team[s] to assume as already present on the destination server. Implies --check-missing-teams. The flag can be repeated")
ImportValidateCmd.Flags().Bool("check-missing-teams", false, "Check for teams that are not defined but referenced in the archive")
ImportValidateCmd.Flags().Bool("ignore-attachments", false, "Don't check if the attached files are present in the archive")
ImportValidateCmd.Flags().Bool("check-server-duplicates", true, "Set to false to ignore teams, channels, and users already present on the server")
ImportProcessCmd.Flags().Bool("bypass-upload", false, "If this is set, the file is not processed from the server, but rather directly read from the filesystem. Works only in --local mode.")
ImportProcessCmd.Flags().Bool("extract-content", true, "If this is set, document attachments will be extracted and indexed during the import process. It is advised to disable it to improve performance.")
ImportProcessCmd.Flags().Int("workers", 0, "The number of concurrent import worker goroutines. Controls database load during import. When set to 0 (default), uses the number of CPUs available. Maximum allowed is 4x the CPU count.")
ImportListCmd.AddCommand(
ImportListAvailableCmd,
ImportListIncompleteCmd,
)
ImportJobCmd.AddCommand(
ImportJobListCmd,
ImportJobShowCmd,
)
ImportCmd.AddCommand(
ImportUploadCmd,
ImportListCmd,
ImportProcessCmd,
ImportJobCmd,
ImportValidateCmd,
ImportDeleteCmd,
)
RootCmd.AddCommand(ImportCmd)
}
func importListIncompleteCmdF(c client.Client, command *cobra.Command, args []string) error {
isLocal, _ := command.Flags().GetBool("local")
userID := "me"
if isLocal {
userID = model.UploadNoUserID
}
uploads, _, err := c.GetUploadsForUser(context.TODO(), userID)
if err != nil {
return fmt.Errorf("failed to get uploads: %w", err)
}
var hasImports bool
for _, us := range uploads {
if us.Type == model.UploadTypeImport {
completedPct := float64(us.FileOffset) / float64(us.FileSize) * 100
printer.PrintT(fmt.Sprintf(" ID: {{.Id}}\n Name: {{.Filename}}\n Uploaded: {{.FileOffset}}/{{.FileSize}} (%0.0f%%)\n", completedPct), us)
hasImports = true
}
}
if !hasImports {
printer.Print("No incomplete import uploads found")
return nil
}
return nil
}
func importListAvailableCmdF(c client.Client, command *cobra.Command, args []string) error {
imports, _, err := c.ListImports(context.TODO())
if err != nil {
return fmt.Errorf("failed to list imports: %w", err)
}
if len(imports) == 0 {
printer.Print("No import files found")
return nil
}
for _, name := range imports {
printer.Print(name)
}
return nil
}
func importUploadCmdF(c client.Client, command *cobra.Command, args []string) error {
filepath := args[0]
isLocal, _ := command.Flags().GetBool("local")
if isLocal {
printer.PrintWarning("In --local mode, you don't need to upload the file to server any more. Directly use the import process command with the --bypass-upload flag and pass the export file.")
}
file, err := os.Open(filepath)
if err != nil {
return fmt.Errorf("failed to open import file: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat import file: %w", err)
}
shouldResume, _ := command.Flags().GetBool("resume")
var us *model.UploadSession
if shouldResume {
uploadID, nErr := command.Flags().GetString("upload")
if nErr != nil || !model.IsValidId(uploadID) {
return errors.New("upload session ID is missing or invalid")
}
us, _, err = c.GetUpload(context.TODO(), uploadID)
if err != nil {
return fmt.Errorf("failed to get upload session: %w", err)
}
if us.FileSize != info.Size() {
return fmt.Errorf("file sizes do not match")
}
if _, nErr := file.Seek(us.FileOffset, io.SeekStart); nErr != nil {
return fmt.Errorf("failed to get seek file: %w", nErr)
}
} else {
isLocal, _ := command.Flags().GetBool("local")
userID := "me"
if isLocal {
userID = model.UploadNoUserID
}
us, _, err = c.CreateUpload(context.TODO(), &model.UploadSession{
Filename: info.Name(),
FileSize: info.Size(),
Type: model.UploadTypeImport,
UserId: userID,
})
if err != nil {
return fmt.Errorf("failed to create upload session: %w", err)
}
printer.PrintT("Upload session successfully created, ID: {{.Id}} ", us)
}
finfo, _, err := c.UploadData(context.TODO(), us.Id, file)
if err != nil {
return fmt.Errorf("failed to upload data: %w", err)
}
printer.PrintT("Import file successfully uploaded, name: {{.Id}}", finfo)
return nil
}
func importDeleteCmdF(c client.Client, command *cobra.Command, args []string) error {
importName := args[0]
if _, err := c.DeleteImport(context.TODO(), importName); err != nil {
return fmt.Errorf("failed to delete import: %w", err)
}
printer.Print(fmt.Sprintf("Import file %q has been deleted", importName))
return nil
}
func importProcessCmdF(c client.Client, command *cobra.Command, args []string) error {
importFile := args[0]
isLocal, _ := command.Flags().GetBool("local")
bypassUpload, _ := command.Flags().GetBool("bypass-upload")
if bypassUpload {
if isLocal {
// First, we validate whether the server is in HA.
config, _, err := c.GetClientConfig(context.TODO(), "")
if err != nil {
return err
}
enableCluster, err := strconv.ParseBool(config["EnableCluster"])
if err != nil {
return fmt.Errorf("failed to parse EnableCluster: %w", err)
}
if enableCluster {
return errors.New("--bypass-upload flag doesn't work if the server is in HA. Because the file has to be present locally on the server where the job request hits. Please disable HA and try again.")
}
// in local mode, we tell the server to directly read from this file.
if _, err := os.Stat(importFile); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file %s doesn't exist. NOTE: If this file was uploaded to the server via mmctl import upload, please omit the --bypass-upload flag to revert to old behavior.", importFile)
}
// If it's not an absolute path, then we make it
if !path.IsAbs(importFile) {
var err2 error
importFile, err2 = filepath.Abs(importFile)
if err2 != nil {
return fmt.Errorf("error is getting the absolute path to %s: %w", importFile, err2)
}
}
} else {
printer.PrintWarning("--bypass-upload has no effect in non-local mode.")
}
}
extractContent, _ := command.Flags().GetBool("extract-content")
workers, _ := command.Flags().GetInt("workers")
maxWorkers := runtime.NumCPU() * 4
if workers > maxWorkers {
return fmt.Errorf("workers value %d exceeds maximum allowed (%d = 4 * CPU count)", workers, maxWorkers)
}
jobData := map[string]string{
"import_file": importFile,
"local_mode": strconv.FormatBool(isLocal && bypassUpload),
"extract_content": strconv.FormatBool(extractContent),
}
if workers > 0 {
jobData["workers"] = strconv.Itoa(workers)
}
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: model.JobTypeImportProcess,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to create import process job: %w", err)
}
printer.PrintT("Import process job successfully created, ID: {{.Id}}", job)
return nil
}
func importJobShowCmdF(c client.Client, command *cobra.Command, args []string) error {
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get import job: %w", err)
}
printJob(job)
return nil
}
func importJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
return jobListCmdF(c, command, model.JobTypeImportProcess, "")
}
type Statistics struct {
Roles uint64 `json:"roles"`
Schemes uint64 `json:"schemes"`
Teams uint64 `json:"teams"`
Channels uint64 `json:"channels"`
Users uint64 `json:"users"`
Emojis uint64 `json:"emojis"`
Posts uint64 `json:"posts"`
DirectChannels uint64 `json:"direct_channels"`
DirectPosts uint64 `json:"direct_posts"`
Attachments uint64 `json:"attachments"`
}
type ImportValidationResult struct {
FileName string `json:"file_name"`
TotalLines uint64 `json:"total_lines"`
Elapsed time.Duration `json:"elapsed_time_ns"`
Errors []*importer.ImportValidationError `json:"errors"`
}
func importValidateCmdF(c client.Client, command *cobra.Command, args []string) error {
configurePrinter()
defer printer.Print("Validation complete\n")
var (
serverTeams = make(map[string]*model.Team) // initialize it in case we need to add teams manually
serverChannels map[importer.ChannelTeam]*model.Channel
serverUsers map[string]*model.User
serverEmails map[string]*model.User
maxPostSize int
)
preRunWithClient := func(c client.Client, cmd *cobra.Command, args []string) error {
users, err := getPages(func(page, numPerPage int, etag string) ([]*model.User, *model.Response, error) {
return c.GetUsers(context.TODO(), page, numPerPage, etag)
}, DefaultPageSize)
if err != nil {
return err
}
config, _, err := c.GetClientConfig(context.TODO(), "")
if err != nil {
return err
}
maxPostSize, err = strconv.Atoi(config["MaxPostSize"])
if err != nil {
return fmt.Errorf("failed to parse MaxPostSize: %w", err)
}
serverUsers = make(map[string]*model.User)
serverEmails = make(map[string]*model.User)
for _, user := range users {
serverUsers[user.Nickname] = user
serverEmails[user.Email] = user
}
teams, err := getPages(func(page, numPerPage int, etag string) ([]*model.Team, *model.Response, error) {
return c.GetAllTeams(context.TODO(), etag, page, numPerPage)
}, DefaultPageSize)
if err != nil {
return err
}
serverChannels = make(map[importer.ChannelTeam]*model.Channel)
for _, team := range teams {
serverTeams[team.Name] = team
publicChannels, err := getPages(func(page, numPerPage int, etag string) ([]*model.Channel, *model.Response, error) {
return c.GetPublicChannelsForTeam(context.TODO(), team.Id, page, numPerPage, etag)
}, DefaultPageSize)
if err != nil {
return err
}
privateChannels, err := getPages(func(page, numPerPage int, etag string) ([]*model.Channel, *model.Response, error) {
return c.GetPrivateChannelsForTeam(context.TODO(), team.Id, page, numPerPage, etag)
}, DefaultPageSize)
if err != nil {
return err
}
for _, channel := range publicChannels {
serverChannels[importer.ChannelTeam{Channel: channel.Name, Team: team.Name}] = channel
}
for _, channel := range privateChannels {
serverChannels[importer.ChannelTeam{Channel: channel.Name, Team: team.Name}] = channel
}
}
return nil
}
var err error
if c != nil {
err = preRunWithClient(c, command, args)
} else {
err = withClient(preRunWithClient)(command, args)
}
if err != nil {
printer.Print(fmt.Sprintf("could not initialize client (%s), skipping online checks\n", err.Error()))
}
injectedTeams, err := command.Flags().GetStringArray("team")
if err != nil {
return err
}
for _, team := range injectedTeams {
if _, ok := serverTeams[team]; !ok {
serverTeams[team] = &model.Team{
Id: "<predefined>",
Name: team,
DisplayName: "team was predefined",
}
}
}
checkMissingTeams, err := command.Flags().GetBool("check-missing-teams")
if err != nil {
return err
}
ignoreAttachments, err := command.Flags().GetBool("ignore-attachments")
if err != nil {
return err
}
checkServerDuplicates, err := command.Flags().GetBool("check-server-duplicates")
if err != nil {
return err
}
if maxPostSize == 0 {
maxPostSize = model.PostMessageMaxRunesV2
}
createMissingTeams := !checkMissingTeams && len(injectedTeams) == 0
validator := importer.NewValidator(
args[0], // input file
ignoreAttachments, // ignore attachments flag
createMissingTeams, // create missing teams flag
checkServerDuplicates, // check for server duplicates flag
serverTeams, // map of existing teams
serverChannels, // map of existing channels
serverUsers, // map of users by name
serverEmails, // map of users by email
maxPostSize,
)
var errors []*importer.ImportValidationError
templateError := template.Must(template.New("").Parse("{{ .Error }}\n"))
validator.OnError(func(ive *importer.ImportValidationError) error {
printer.PrintPreparedT(templateError, ive)
errors = append(errors, ive)
return nil
})
err = validator.Validate()
if err != nil {
return err
}
stat := Statistics{
Roles: validator.Roles(),
Schemes: validator.Schemes(),
Teams: validator.TeamCount(),
Channels: validator.ChannelCount(),
Users: validator.UserCount(),
Posts: (validator.PostCount()),
DirectChannels: (validator.DirectChannelCount()),
DirectPosts: (validator.DirectPostCount()),
Emojis: (validator.Emojis()),
Attachments: uint64(len(validator.Attachments())),
}
printStatistics(stat)
createdTeams := validator.CreatedTeams()
if createMissingTeams && len(createdTeams) != 0 {
printer.PrintT("Automatically created teams: {{ join .CreatedTeams \", \" }}\n", struct {
CreatedTeams []string `json:"created_teams"`
}{createdTeams})
}
unusedAttachments := validator.UnusedAttachments()
if len(unusedAttachments) > 0 {
printer.PrintT("Unused Attachments ({{ len .UnusedAttachments }}):\n"+
"{{ range .UnusedAttachments }} {{ . }}\n{{ end }}", struct {
UnusedAttachments []string `json:"unused_attachments"`
}{unusedAttachments})
}
printer.PrintT("It took {{ .Elapsed }} to validate {{ .TotalLines }} lines in {{ .FileName }}\n", ImportValidationResult{args[0], validator.Lines(), validator.Duration(), errors})
return nil
}
func configurePrinter() {
// we want to manage the newlines ourselves
printer.SetNoNewline(true)
// define a join function
printer.SetTemplateFunc("join", strings.Join)
}
func printStatistics(stat Statistics) {
tmpl := "\n" +
"Roles {{ .Roles }}\n" +
"Schemes {{ .Schemes }}\n" +
"Teams {{ .Teams }}\n" +
"Channels {{ .Channels }}\n" +
"Users {{ .Users }}\n" +
"Emojis {{ .Emojis }}\n" +
"Posts {{ .Posts }}\n" +
"Direct Channels {{ .DirectChannels }}\n" +
"Direct Posts {{ .DirectPosts }}\n" +
"Attachments {{ .Attachments }}\n"
printer.PrintT(tmpl, stat)
}

View File

@@ -0,0 +1,484 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/mattermost/mattermost/server/v8"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestImportUploadCmdF() {
s.SetupTestHelper().InitBasic(s.T())
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
info, err := os.Stat(importFilePath)
s.Require().NoError(err)
s.Run("no permissions", func() {
printer.Clean()
err := importUploadCmdF(s.th.Client, &cobra.Command{}, []string{importFilePath})
s.Require().NotNil(err)
s.Require().Equal("failed to create upload session: You do not have the appropriate permissions.", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("invalid file", func(c client.Client) {
printer.Clean()
err := importUploadCmdF(s.th.Client, &cobra.Command{}, []string{"invalid_file"})
s.Require().NotNil(err)
s.Require().Equal("failed to open import file: open invalid_file: no such file or directory", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("full upload", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
if c == s.th.LocalClient {
cmd.Flags().Bool("local", true, "")
}
err := importUploadCmdF(c, cmd, []string{importFilePath})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(importName, printer.GetLines()[0].(*model.UploadSession).Filename)
s.Require().Equal(importName, printer.GetLines()[1].(*model.FileInfo).Name)
})
s.RunForSystemAdminAndLocal("resume upload", func(c client.Client) {
printer.Clean()
userID := "me"
cmd := &cobra.Command{}
if c == s.th.LocalClient {
cmd.Flags().Bool("local", true, "")
userID = "nouser"
}
us, _, err := c.CreateUpload(context.TODO(), &model.UploadSession{
Filename: importName,
FileSize: info.Size(),
Type: model.UploadTypeImport,
UserId: userID,
})
s.Require().NoError(err)
cmd.Flags().Bool("resume", true, "")
cmd.Flags().String("upload", us.Id, "")
err = importUploadCmdF(c, cmd, []string{importFilePath})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(importName, printer.GetLines()[0].(*model.FileInfo).Name)
})
}
func (s *MmctlE2ETestSuite) TestImportProcessCmdF() {
s.SetupTestHelper().InitBasic(s.T())
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", "import_test.zip")
s.Run("no permissions", func() {
printer.Clean()
err := importProcessCmdF(s.th.Client, &cobra.Command{}, []string{"importName"})
s.Require().NotNil(err)
s.Require().Equal("failed to create import process job: You do not have the appropriate permissions.", err.Error())
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("process file", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
if c == s.th.LocalClient {
cmd.Flags().Bool("local", true, "")
}
err := importUploadCmdF(c, cmd, []string{importFilePath})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Empty(printer.GetErrorLines())
us := printer.GetLines()[0].(*model.UploadSession)
printer.Clean()
err = importProcessCmdF(c, cmd, []string{us.Id + "_" + importName})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(us.Id+"_"+importName, printer.GetLines()[0].(*model.Job).Data["import_file"])
})
}
func (s *MmctlE2ETestSuite) TestImportListAvailableCmdF() {
s.SetupTestHelper().InitBasic(s.T())
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
s.Run("no permissions", func() {
printer.Clean()
err := importListAvailableCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().NotNil(err)
s.Require().ErrorContains(err, "failed to list imports: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("no imports", func(c client.Client) {
printer.Clean()
err := importListAvailableCmdF(c, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No import files found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("some imports", func(c client.Client) {
cmd := &cobra.Command{}
if c == s.th.LocalClient {
cmd.Flags().Bool("local", true, "")
}
numImports := 3
for range numImports {
err := importUploadCmdF(c, cmd, []string{importFilePath})
s.Require().Nil(err)
}
printer.Clean()
imports, appErr := s.th.App.ListImports()
s.Require().Nil(appErr)
err := importListAvailableCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), len(imports))
s.Require().Empty(printer.GetErrorLines())
for i, name := range printer.GetLines() {
s.Require().Equal(imports[i], name.(string))
}
})
}
func (s *MmctlE2ETestSuite) TestImportListIncompleteCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForAllClients("no incomplete import uploads", func(c client.Client) {
printer.Clean()
err := importListIncompleteCmdF(c, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No incomplete import uploads found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("some incomplete import uploads", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
userID := "nouser"
if c == s.th.SystemAdminClient {
user, _, err := s.th.SystemAdminClient.GetMe(context.Background(), "")
s.Require().NoError(err)
userID = user.Id
} else {
cmd.Flags().Bool("local", true, "")
}
us1, appErr := s.th.App.CreateUploadSession(s.th.Context, &model.UploadSession{
Id: model.NewId(),
UserId: userID,
Type: model.UploadTypeImport,
Filename: "import1.zip",
FileSize: 1024 * 1024,
})
s.Require().Nil(appErr)
us1.Path = ""
time.Sleep(time.Millisecond)
_, appErr = s.th.App.CreateUploadSession(s.th.Context, &model.UploadSession{
Id: model.NewId(),
UserId: userID,
ChannelId: s.th.BasicChannel.Id,
Type: model.UploadTypeAttachment,
Filename: "somefile",
FileSize: 1024 * 1024,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
us3, appErr := s.th.App.CreateUploadSession(s.th.Context, &model.UploadSession{
Id: model.NewId(),
UserId: userID,
Type: model.UploadTypeImport,
Filename: "import2.zip",
FileSize: 1024 * 1024,
})
s.Require().Nil(appErr)
us3.Path = ""
err := importListIncompleteCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(us1, printer.GetLines()[0].(*model.UploadSession))
s.Require().Equal(us3, printer.GetLines()[1].(*model.UploadSession))
})
}
func (s *MmctlE2ETestSuite) TestImportJobShowCmdF() {
s.SetupTestHelper().InitBasic(s.T())
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{"import_file": "import1.zip"},
})
s.Require().Nil(appErr)
s.Run("no permissions", func() {
printer.Clean()
job1, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{"import_file": "import1.zip"},
})
s.Require().Nil(appErr)
err := importJobShowCmdF(s.th.Client, &cobra.Command{}, []string{job1.Id})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "failed to get import job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("not found", func(c client.Client) {
printer.Clean()
err := importJobShowCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "failed to get import job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("found", func(c client.Client) {
printer.Clean()
err := importJobShowCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestImportJobListCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("no permissions", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := importJobListCmdF(s.th.Client, cmd, nil)
s.Require().NotNil(err)
s.Require().ErrorContains(err, "failed to get jobs: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("no import jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := importJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("some import jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
_, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{"import_file": "import1.zip"},
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job2, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{"import_file": "import2.zip"},
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job3, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{"import_file": "import3.zip"},
})
s.Require().Nil(appErr)
err := importJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), perPage)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(job3, printer.GetLines()[0].(*model.Job))
s.Require().Equal(job2, printer.GetLines()[1].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestImportValidateCmdF() {
s.SetupTestHelper().InitBasic(s.T())
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
s.RunForSystemAdminAndLocal("defaults", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().StringArray("team", nil, "")
cmd.Flags().Bool("check-missing-teams", false, "")
cmd.Flags().Bool("ignore-attachments", false, "")
cmd.Flags().Bool("check-server-duplicates", true, "")
err := importValidateCmdF(c, cmd, []string{importFilePath})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(Statistics{
Teams: 2,
Channels: 24,
Users: 16,
Posts: 2001,
DirectChannels: 79,
DirectPosts: 901,
Attachments: 2,
}, printer.GetLines()[0].(Statistics))
s.Require().Equal(struct {
UnusedAttachments []string `json:"unused_attachments"`
}{
UnusedAttachments: []string{"data/test2.png", "data/test_img_diff_A.png", "data/test_img_diff_B.png"},
}, printer.GetLines()[1].(struct {
UnusedAttachments []string `json:"unused_attachments"`
}))
s.Require().Equal("Validation complete\n", printer.GetLines()[3])
})
s.RunForSystemAdminAndLocal("ignore attachments", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().StringArray("team", nil, "")
cmd.Flags().Bool("check-missing-teams", false, "")
cmd.Flags().Bool("ignore-attachments", true, "")
cmd.Flags().Bool("check-server-duplicates", true, "")
err := importValidateCmdF(c, cmd, []string{importFilePath})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(Statistics{
Teams: 2,
Channels: 24,
Users: 16,
Posts: 2001,
DirectChannels: 79,
DirectPosts: 901,
Attachments: 0,
}, printer.GetLines()[0].(Statistics))
s.Require().Equal("Validation complete\n", printer.GetLines()[2])
})
}
func (s *MmctlE2ETestSuite) TestImportDeleteCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("no permissions", func() {
printer.Clean()
err := importDeleteCmdF(s.th.Client, &cobra.Command{}, []string{"import1.zip"})
s.Require().EqualError(err, "failed to delete import: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("delete import", func(c client.Client) {
importName := "import_test.zip"
importFilePath := filepath.Join(server.GetPackagePath(), "tests", importName)
importPath, err := filepath.Abs(filepath.Join(*s.th.App.Config().FileSettings.Directory,
*s.th.App.Config().ImportSettings.Directory))
s.Require().Nil(err)
cmd := &cobra.Command{}
newImportName := "new_import_test.zip"
err = utils.CopyFile(importFilePath, filepath.Join(importPath, newImportName))
s.Require().Nil(err)
printer.Clean()
imports, appErr := s.th.App.ListImports()
s.Require().Nil(appErr)
s.Require().NotEmpty(imports)
s.Require().Equal(newImportName, imports[0])
err = importDeleteCmdF(c, cmd, []string{newImportName})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Equal(fmt.Sprintf(`Import file "%s" has been deleted`, newImportName), printer.GetLines()[0])
imports, appErr = s.th.App.ListImports()
s.Require().Nil(appErr)
s.Require().Empty(imports)
//idempotency check
err = importDeleteCmdF(c, cmd, []string{newImportName})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 2)
s.Equal(fmt.Sprintf(`Import file "%s" has been deleted`, newImportName), printer.GetLines()[0])
})
}

View File

@@ -0,0 +1,619 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"archive/zip"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
)
func (s *MmctlUnitTestSuite) TestImportListAvailableCmdF() {
s.Run("no imports", func() {
printer.Clean()
var mockImports []string
s.client.
EXPECT().
ListImports(context.TODO()).
Return(mockImports, &model.Response{}, nil).
Times(1)
err := importListAvailableCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Equal("No import files found", printer.GetLines()[0])
})
s.Run("some imports", func() {
printer.Clean()
mockImports := []string{
"import1.zip",
"import2.zip",
"import3.zip",
}
s.client.
EXPECT().
ListImports(context.TODO()).
Return(mockImports, &model.Response{}, nil).
Times(1)
err := importListAvailableCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockImports))
s.Len(printer.GetErrorLines(), 0)
for i, line := range printer.GetLines() {
s.Equal(mockImports[i], line)
}
})
}
func (s *MmctlUnitTestSuite) TestImportListIncompleteCmdF() {
s.Run("no incomplete uploads", func() {
printer.Clean()
var mockUploads []*model.UploadSession
s.client.
EXPECT().
GetUploadsForUser(context.TODO(), "me").
Return(mockUploads, &model.Response{}, nil).
Times(1)
err := importListIncompleteCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal("No incomplete import uploads found", printer.GetLines()[0])
})
s.Run("some incomplete uploads", func() {
printer.Clean()
mockUploads := []*model.UploadSession{
{
Id: model.NewId(),
Type: model.UploadTypeImport,
},
{
Id: model.NewId(),
Type: model.UploadTypeAttachment,
},
{
Id: model.NewId(),
Type: model.UploadTypeImport,
},
}
s.client.
EXPECT().
GetUploadsForUser(context.TODO(), "me").
Return(mockUploads, &model.Response{}, nil).
Times(1)
err := importListIncompleteCmdF(s.client, &cobra.Command{}, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 2)
s.Empty(printer.GetErrorLines())
s.Equal(mockUploads[0], printer.GetLines()[0].(*model.UploadSession))
s.Equal(mockUploads[2], printer.GetLines()[1].(*model.UploadSession))
})
}
func (s *MmctlUnitTestSuite) TestImportJobShowCmdF() {
s.Run("not found", func() {
printer.Clean()
jobID := model.NewId()
s.client.
EXPECT().
GetJob(context.TODO(), jobID).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("not found")).
Times(1)
err := importJobShowCmdF(s.client, &cobra.Command{}, []string{jobID})
s.Require().NotNil(err)
s.Empty(printer.GetLines())
s.Empty(printer.GetErrorLines())
})
s.Run("found", func() {
printer.Clean()
mockJob := &model.Job{
Id: model.NewId(),
}
s.client.
EXPECT().
GetJob(context.TODO(), mockJob.Id).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := importJobShowCmdF(s.client, &cobra.Command{}, []string{mockJob.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlUnitTestSuite) TestImportJobListCmdF() {
s.Run("no import jobs", func() {
printer.Clean()
var mockJobs []*model.Job
cmd := &cobra.Command{}
perPage := 10
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetJobs(context.TODO(), model.JobTypeImportProcess, "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := importJobListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.Run("some import jobs", func() {
printer.Clean()
mockJobs := []*model.Job{
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
}
cmd := &cobra.Command{}
perPage := 3
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetJobs(context.TODO(), model.JobTypeImportProcess, "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := importJobListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockJobs))
s.Empty(printer.GetErrorLines())
for i, line := range printer.GetLines() {
s.Equal(mockJobs[i], line.(*model.Job))
}
})
}
func (s *MmctlUnitTestSuite) TestImportProcessCmdF() {
s.Run("default workers", func() {
printer.Clean()
importFile := "import.zip"
mockJob := &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{
"import_file": importFile,
"local_mode": "false",
"extract_content": "false",
},
}
s.client.
EXPECT().
CreateJob(context.TODO(), mockJob).
Return(mockJob, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("bypass-upload", false, "")
cmd.Flags().Bool("extract-content", false, "")
cmd.Flags().Int("workers", 0, "")
err := importProcessCmdF(s.client, cmd, []string{importFile})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("workers exceeds max", func() {
printer.Clean()
importFile := "import.zip"
tooMany := runtime.NumCPU()*4 + 1
cmd := &cobra.Command{}
cmd.Flags().Bool("bypass-upload", false, "")
cmd.Flags().Bool("extract-content", false, "")
cmd.Flags().Int("workers", 0, "")
_ = cmd.Flags().Set("workers", strconv.Itoa(tooMany))
err := importProcessCmdF(s.client, cmd, []string{importFile})
s.Require().NotNil(err)
s.Contains(err.Error(), "exceeds maximum allowed")
s.Empty(printer.GetLines())
s.Empty(printer.GetErrorLines())
})
s.Run("custom workers", func() {
printer.Clean()
importFile := "import.zip"
mockJob := &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{
"import_file": importFile,
"local_mode": "false",
"extract_content": "false",
"workers": "2",
},
}
s.client.
EXPECT().
CreateJob(context.TODO(), mockJob).
Return(mockJob, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("bypass-upload", false, "")
cmd.Flags().Bool("extract-content", false, "")
cmd.Flags().Int("workers", 0, "")
_ = cmd.Flags().Set("workers", "2")
err := importProcessCmdF(s.client, cmd, []string{importFile})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlUnitTestSuite) TestImportValidateCmdF() {
importFilePath := filepath.Join(os.TempDir(), "import.zip")
importBase := `{"type":"version","version":1}
{"type":"team","team":{"name":"reiciendis-0","display_name":"minus","type":"O","description":"doloremque dignissimos velit eum quae non omnis. dolores rerum cupiditate porro quia aperiam necessitatibus natus aut. velit eveniet porro explicabo tempora voluptas beatae. eum saepe a aut. perferendis aut ab ipsum! molestias animi ut porro dolores vel. ","allow_open_invite":false}}
{"type":"team","team":{"name":"ad-1","display_name":"eligendi","type":"O","description":"et iste illum reprehenderit aliquid in rem itaque in maxime eius.","allow_open_invite":false}}
{"type":"channel","channel":{"team":"ad-1","name":"iusto-9","display_name":"incidunt","type":"P","header":"officia accusamus aut aliquid dolor qui. quia magni pariatur numquam nesciunt. maxime dolorum sit neque commodi dolorum qui dicta sit. labore laudantium quisquam voluptatem commodi magnam. est aliquid perspiciatis sequi adipisci modi sit nam. totam iste quidem sed mollitia earum. vel voluptates labore cumque eaque qui!","purpose":"sit et accusamus repudiandae id. ut et officiis eos quod. sit soluta aliquid pariatur consectetur nostrum aut magni. numquam quas aspernatur et voluptatum et ipsam animi."}}
{"type":"user","user":{"username":"ashley.berry","email":"user-12@sample.mattermost.com","auth_service":null,"nickname":"","first_name":"Ashley","last_name":"Berry","position":"Registered Nurse","roles":"system_user","locale":"en","delete_at":0,"teams":[{"name":"reiciendis-0","roles":"team_admin team_user","channels":[{"name":"town-square","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"doloremque-0","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"voluptas-9","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"minus-8","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"rem-7","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"odit-3","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":true}]},{"name":"ad-1","roles":"team_user","channels":[{"name":"iusto-9","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"amet-0","roles":"channel_admin channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"minus-6","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"autem-2","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"town-square","roles":"channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false},{"name":"aut-8","roles":"channel_admin channel_user","notify_props":{"desktop":"default","mobile":"default","mark_unread":"all"},"favorite":false}]}],"military_time":"false","link_previews":"true","message_display":"compact","channel_display_mode":"full","tutorial_step":"2","notify_props":{"desktop":"mention","desktop_sound":"true","email":"true","mobile":"mention","mobile_push_status":"away","channel":"true","comments":"never","mention_keys":""}}}
{"type":"direct_channel","direct_channel":{"members":["ashley.berry","ashley.berry"],"favorited_by":null,"header":""}}`
s.Run("empty file", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
_, err = wr.Write([]byte(``))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
err = importValidateCmdF(nil, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Len(printer.GetLines(), 5)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{}, printer.GetLines()[2].(Statistics))
s.Equal("Validation complete\n", printer.GetLines()[4])
})
s.Run("post size under default limit", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
msg := strings.Repeat("t", model.PostMessageMaxRunesV2)
_, err = wr.Write([]byte(importBase))
s.Require().NoError(err)
_, err = wr.Write(fmt.Appendf(nil, `
{"type":"post","post":{"team":"ad-1","channel":"iusto-9","user":"ashley.berry","message":"%s","props":{},"create_at":1603398068740,"reactions":null,"replies":null}}`, msg))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
err = importValidateCmdF(nil, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{
Teams: 2,
Channels: 1,
DirectChannels: 1,
Users: 1,
Posts: 1,
}, printer.GetLines()[0].(Statistics))
res := printer.GetLines()[1].(ImportValidationResult)
s.Require().Empty(res.Errors)
s.Equal("Validation complete\n", printer.GetLines()[2])
})
s.Run("post size above default limit", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
msg := strings.Repeat("t", model.PostMessageMaxRunesV2+1)
_, err = wr.Write([]byte(importBase))
s.Require().NoError(err)
_, err = wr.Write(fmt.Appendf(nil, `
{"type":"post","post":{"team":"ad-1","channel":"iusto-9","user":"ashley.berry","message":"%s","props":{},"create_at":1603398068740,"reactions":null,"replies":null}}`, msg))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
err = importValidateCmdF(nil, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{
Teams: 2,
Channels: 1,
DirectChannels: 1,
Users: 1,
Posts: 1,
}, printer.GetLines()[0].(Statistics))
res := printer.GetLines()[1].(ImportValidationResult)
s.Require().Len(res.Errors, 1)
s.Require().Equal("app.import.validate_post_import_data.message_length.error", res.Errors[0].Err.(*model.AppError).Id)
s.Equal("Validation complete\n", printer.GetLines()[2])
})
s.Run("post size below config limit", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
msg := strings.Repeat("t", model.PostMessageMaxRunesV2*2)
_, err = wr.Write([]byte(importBase))
s.Require().NoError(err)
_, err = wr.Write(fmt.Appendf(nil, `
{"type":"post","post":{"team":"ad-1","channel":"iusto-9","user":"ashley.berry","message":"%s","props":{},"create_at":1603398068740,"reactions":null,"replies":null}}`, msg))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
s.client.
EXPECT().
GetUsers(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetAllTeams(context.TODO(), "", 0, 200).
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetClientConfig(context.TODO(), "").
Return(map[string]string{
"MaxPostSize": fmt.Sprintf("%d", model.PostMessageMaxRunesV2*2),
}, &model.Response{}, nil).
Times(1)
err = importValidateCmdF(s.client, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{
Teams: 2,
Channels: 1,
DirectChannels: 1,
Users: 1,
Posts: 1,
}, printer.GetLines()[0].(Statistics))
res := printer.GetLines()[1].(ImportValidationResult)
s.Require().Empty(res.Errors)
s.Equal("Validation complete\n", printer.GetLines()[2])
})
s.Run("direct post size below config limit", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
msg := strings.Repeat("t", model.PostMessageMaxRunesV2*2)
_, err = wr.Write([]byte(importBase))
s.Require().NoError(err)
_, err = wr.Write(fmt.Appendf(nil, `
{"type":"direct_post","direct_post":{"channel_members":["ashley.berry","ashley.berry"],"user":"ashley.berry","message":"%s","props":{},"create_at":1603398112372,"flagged_by":null,"reactions":null,"replies":null,"attachments":null}}`, msg))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
s.client.
EXPECT().
GetUsers(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetAllTeams(context.TODO(), "", 0, 200).
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetClientConfig(context.TODO(), "").
Return(map[string]string{
"MaxPostSize": fmt.Sprintf("%d", model.PostMessageMaxRunesV2*2),
}, &model.Response{}, nil).
Times(1)
err = importValidateCmdF(s.client, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{
Teams: 2,
Channels: 1,
Users: 1,
DirectChannels: 1,
DirectPosts: 1,
}, printer.GetLines()[0].(Statistics))
res := printer.GetLines()[1].(ImportValidationResult)
s.Require().Empty(res.Errors)
s.Equal("Validation complete\n", printer.GetLines()[2])
})
s.Run("invalid file attachment path", func() {
file, err := os.Create(importFilePath)
s.Require().NoError(err)
zipWr := zip.NewWriter(file)
wr, err := zipWr.Create("import.jsonl")
s.Require().NoError(err)
_, err = wr.Write([]byte(importBase))
s.Require().NoError(err)
_, err = wr.Write([]byte(`
{"type":"post","post":{"team":"ad-1","channel":"iusto-9","user":"ashley.berry","message":"message","props":{},"create_at":1603398068740,"reactions":null,"replies":null,"attachments":[{"path": "data/../../invalid.jpg"}]}}`))
s.Require().NoError(err)
err = zipWr.Close()
s.Require().NoError(err)
err = file.Close()
s.Require().NoError(err)
printer.Clean()
s.client.
EXPECT().
GetUsers(context.TODO(), 0, 200, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetAllTeams(context.TODO(), "", 0, 200).
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetClientConfig(context.TODO(), "").
Return(map[string]string{
"MaxPostSize": fmt.Sprintf("%d", model.PostMessageMaxRunesV2*2),
}, &model.Response{}, nil).
Times(1)
err = importValidateCmdF(s.client, ImportValidateCmd, []string{importFilePath})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
s.Equal(Statistics{
Teams: 2,
Channels: 1,
DirectChannels: 1,
Users: 1,
Posts: 1,
}, printer.GetLines()[0].(Statistics))
res := printer.GetLines()[1].(ImportValidationResult)
s.Require().Len(res.Errors, 2)
s.Require().Equal("app.import.validate_post_import_data.attachment.error", res.Errors[0].Err.(*model.AppError).Id)
s.Equal("Validation complete\n", printer.GetLines()[2])
})
}
func (s *MmctlUnitTestSuite) TestDeleteImportCmdF() {
s.Run("delete command succeeds", func() {
printer.Clean()
s.client.
EXPECT().
DeleteImport(context.TODO(), "import.zip").
Return(&model.Response{}, nil).
Times(2)
err := importDeleteCmdF(s.client, &cobra.Command{}, []string{"import.zip"})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Equal("Import file \"import.zip\" has been deleted", printer.GetLines()[0])
//idempotency check
err = importDeleteCmdF(s.client, &cobra.Command{}, []string{"import.zip"})
s.Require().Nil(err)
s.Len(printer.GetLines(), 2)
s.Equal("Import file \"import.zip\" has been deleted", printer.GetLines()[1])
})
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package importer
import (
"encoding/json"
"fmt"
"strings"
)
type ImportFileInfo struct {
Source string `json:"archive_name"`
FileName string `json:"file_name,omitempty"`
CurrentLine uint64 `json:"current_line,omitempty"`
TotalLines uint64 `json:"total_lines,omitempty"`
}
type ImportValidationError struct { //nolint:govet
ImportFileInfo
FieldName string
Err error
Suggestion string
SuggestedValues []any
ApplySuggestion func(any) error
}
func (e *ImportValidationError) MarshalJSON() ([]byte, error) {
t := struct { //nolint:govet
ImportFileInfo
FieldName string `json:"field_name,omitempty"`
Err string `json:"error,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
SuggestedValues []any `json:"suggested_values,omitempty"`
}{
ImportFileInfo: e.ImportFileInfo,
FieldName: e.FieldName,
Suggestion: e.Suggestion,
SuggestedValues: e.SuggestedValues,
}
if e.Err != nil {
t.Err = e.Err.Error()
}
return json.Marshal(t)
}
func (e *ImportValidationError) Error() string {
msg := &strings.Builder{}
msg.WriteString("import validation error")
if e.FileName != "" || e.Source != "" {
fmt.Fprintf(msg, " in %s->%s:%d", e.Source, e.FileName, e.CurrentLine)
}
if e.FieldName != "" {
fmt.Fprintf(msg, " field %q", e.FieldName)
}
if e.Err != nil {
fmt.Fprintf(msg, ": %s", e.Err)
}
return msg.String()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"runtime"
"slices"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
const (
shellCompletionMaxItems = 50 // Maximum number of items that will be loaded and shown in shell completion.
shellCompleteTimeout = 5 * time.Second
)
var (
insecureSignatureAlgorithms = map[x509.SignatureAlgorithm]bool{
x509.SHA1WithRSA: true,
x509.DSAWithSHA1: true,
x509.ECDSAWithSHA1: true,
}
expectedSocketMode = os.ModeSocket | 0600
)
func CheckVersionMatch(version, serverVersion string) (bool, error) {
mmctlVersionParsed, err := semver.NewVersion(version)
if err != nil {
return false, errors.Wrapf(err, "Cannot parse version range %s", version)
}
// Split and recombine the server version string
parts := strings.Split(serverVersion, ".")
if len(parts) < 3 {
return false, fmt.Errorf("incorrect server version format: %s", serverVersion)
}
serverVersion = strings.Join(parts[:3], ".")
serverVersionParsed, err := semver.NewVersion(serverVersion)
if err != nil {
return false, errors.Wrapf(err, "Cannot parse version range %s", serverVersion)
}
if serverVersionParsed.Major() != mmctlVersionParsed.Major() {
return false, nil
}
if mmctlVersionParsed.Minor() > serverVersionParsed.Minor() {
return false, nil
}
return true, nil
}
func getClient(ctx context.Context, cmd *cobra.Command) (*model.Client4, string, bool, error) {
useLocal := viper.GetBool("local")
if !useLocal {
// Assume local mode if no server address is provided
credentials, err := GetCurrentCredentials()
if err != nil {
cmd.PrintErrln("Warning: Unable to retrieve credentials, assuming --local mode")
useLocal = true
} else if credentials == nil {
cmd.PrintErrln("Warning: No credentials found, assuming --local mode")
useLocal = true
}
}
if useLocal {
c, err := InitUnixClient(viper.GetString("local-socket-path"))
if err != nil {
return nil, "", true, err
}
printer.SetServerAddres("local instance")
return c, "", true, nil
}
c, serverVersion, err := InitClient(ctx, viper.GetBool("insecure-sha1-intermediate"), viper.GetBool("insecure-tls-version"))
if err != nil {
return nil, "", false, err
}
return c, serverVersion, false, nil
}
func withClient(fn func(c client.Client, cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx := context.TODO()
c, serverVersion, local, err := getClient(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if local {
return fn(c, cmd, args)
}
if Version != "unspecified" { // unspecified version indicates that we are on dev mode.
valid, err := CheckVersionMatch(Version, serverVersion)
if err != nil {
return fmt.Errorf("could not check version mismatch: %w", err)
}
if !valid {
if viper.GetBool("strict") {
printer.PrintError("ERROR: server version " + serverVersion + " doesn't match with mmctl version " + Version + ". Strict flag is set, so the command will not be run")
os.Exit(1)
}
if !viper.GetBool("suppress-warnings") {
printer.PrintWarning("server version " + serverVersion + " doesn't match mmctl version " + Version)
}
}
}
printer.SetServerAddres(c.APIURL)
return fn(c, cmd, args)
}
}
func localOnlyPrecheck(cmd *cobra.Command, args []string) {
local := viper.GetBool("local")
if !local {
fmt.Fprintln(os.Stderr, "This command can only be run in local mode")
os.Exit(1)
}
}
func disableLocalPrecheck(cmd *cobra.Command, args []string) {
local := viper.GetBool("local")
if local {
fmt.Fprintln(os.Stderr, "This command cannot be run in local mode")
os.Exit(1)
}
}
func isValidChain(chain []*x509.Certificate) bool {
// check all certs but the root one
certs := chain[:len(chain)-1]
for _, cert := range certs {
if _, ok := insecureSignatureAlgorithms[cert.SignatureAlgorithm]; ok {
return false
}
}
return true
}
func VerifyCertificates(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
// loop over certificate chains
if slices.ContainsFunc(verifiedChains, isValidChain) {
return nil
}
return fmt.Errorf("insecure algorithm found in the certificate chain. Use --insecure-sha1-intermediate flag to ignore. Aborting")
}
func NewAPIv4Client(instanceURL string, allowInsecureSHA1, allowInsecureTLS bool) *model.Client4 {
client := model.NewAPIv4Client(instanceURL)
userAgent := fmt.Sprintf("mmctl/%s (%s)", Version, runtime.GOOS)
client.HTTPHeader = map[string]string{"User-Agent": userAgent}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if allowInsecureTLS {
tlsConfig.MinVersion = tls.VersionTLS10
}
if !allowInsecureSHA1 {
tlsConfig.VerifyPeerCertificate = VerifyCertificates
}
client.HTTPClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment,
},
}
return client
}
func InitClientWithUsernameAndPassword(ctx context.Context, username, password, instanceURL string, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) {
client := NewAPIv4Client(instanceURL, allowInsecureSHA1, allowInsecureTLS)
_, resp, err := client.Login(ctx, username, password)
if err != nil {
return nil, "", checkInsecureTLSError(err, allowInsecureTLS)
}
return client, resp.ServerVersion, nil
}
func InitClientWithMFA(ctx context.Context, username, password, mfaToken, instanceURL string, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) {
client := NewAPIv4Client(instanceURL, allowInsecureSHA1, allowInsecureTLS)
_, resp, err := client.LoginWithMFA(ctx, username, password, mfaToken)
if err != nil {
return nil, "", checkInsecureTLSError(err, allowInsecureTLS)
}
return client, resp.ServerVersion, nil
}
func InitClientWithCredentials(ctx context.Context, credentials *Credentials, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) {
client := NewAPIv4Client(credentials.InstanceURL, allowInsecureSHA1, allowInsecureTLS)
client.AuthType = model.HeaderBearer
client.AuthToken = credentials.AuthToken
_, resp, err := client.GetMe(ctx, "")
if err != nil {
return nil, "", checkInsecureTLSError(err, allowInsecureTLS)
}
return client, resp.ServerVersion, nil
}
func InitClient(ctx context.Context, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) {
credentials, err := GetCurrentCredentials()
if err != nil {
return nil, "", err
}
return InitClientWithCredentials(ctx, credentials, allowInsecureSHA1, allowInsecureTLS)
}
func InitWebSocketClient() (*model.WebSocketClient, error) {
credentials, err := GetCurrentCredentials()
if err != nil {
return nil, err
}
client, appErr := model.NewWebSocketClient4(strings.Replace(credentials.InstanceURL, "http", "ws", 1), credentials.AuthToken)
if appErr != nil {
return nil, errors.Wrap(appErr, "unable to create the websockets connection")
}
return client, nil
}
func InitUnixClient(socketPath string) (*model.Client4, error) {
if err := checkValidSocket(socketPath); err != nil {
return nil, err
}
return model.NewAPIv4SocketClient(socketPath), nil
}
func checkInsecureTLSError(err error, allowInsecureTLS bool) error {
if (strings.Contains(err.Error(), "tls: protocol version not supported") ||
strings.Contains(err.Error(), "tls: server selected unsupported protocol version")) && !allowInsecureTLS {
return errors.New("won't perform action through an insecure TLS connection. Please add --insecure-tls-version to bypass this check")
}
return err
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"crypto/x509"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestCheckVersionMatch(t *testing.T) {
testCases := []struct {
Name string
Version string
ServerVersion string
Expected bool
ErrExpected bool
}{
{
Name: "Both versions are equal",
Version: "1.2.3",
ServerVersion: "1.2.3.dev.993e5a2cb546b0116ecaae1862b6a1c6.true",
Expected: true,
},
{
Name: "Only patch version is different",
Version: "1.2.3",
ServerVersion: "1.2.7.dev.993e5a2cb546b0116ecaae1862b6a1c6.false",
Expected: true,
},
{
Name: "Major version is greater",
Version: "1.2.3",
ServerVersion: "7.0.0.7.0.0.8215d92df0b8458789408eb07ccfdaae.false",
Expected: false,
},
{
Name: "Major version is less",
Version: "8.2.3",
ServerVersion: "7.0.0.7.0.0.8215d92df0b8458789408eb07ccfdaae.false",
Expected: false,
},
{
Name: "Minor version is greater",
Version: "1.2.3",
ServerVersion: "1.3.3.1.3.3.8215d92df0b8458789408eb07ccfdaae.true",
Expected: true,
},
{
Name: "Minor version is less",
Version: "1.2.3",
ServerVersion: "1.1.3.1.1.3.8215d92df0b8458789408eb07ccfdaae.false",
Expected: false,
},
{
Name: "Both versions are equal but one has v in front of it",
Version: "v1.2.3",
ServerVersion: "1.2.3.dev.8215d92df0b8458789408eb07ccfdaae.false",
Expected: true,
},
{
Name: "unspecified version",
Version: "",
ServerVersion: "1.2.3",
Expected: false,
ErrExpected: true,
},
{
Name: "bad version",
Version: "1.2.3",
ServerVersion: "1.2",
Expected: false,
ErrExpected: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
res, err := CheckVersionMatch(tc.Version, tc.ServerVersion)
require.True(t, (err != nil) == tc.ErrExpected)
require.Equal(t, tc.Expected, res)
})
}
}
func TestVerifyCertificates(t *testing.T) {
testCases := []struct {
Name string
Chains [][]*x509.Certificate
ExpectedError bool
}{
{
Name: "One chain with a root SHA1 cert",
Chains: [][]*x509.Certificate{
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
},
},
ExpectedError: false,
},
{
Name: "One chain with an intermediate SHA1 cert",
Chains: [][]*x509.Certificate{
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
},
},
ExpectedError: true,
},
{
Name: "One valid chain and other invalid",
Chains: [][]*x509.Certificate{
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
},
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
},
},
ExpectedError: false,
},
{
Name: "Two invalid chains",
Chains: [][]*x509.Certificate{
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
{SignatureAlgorithm: x509.SHA1WithRSA},
},
{
{SignatureAlgorithm: x509.SHA256WithRSA},
{SignatureAlgorithm: x509.DSAWithSHA1},
{SignatureAlgorithm: x509.DSAWithSHA1},
},
},
ExpectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
err := VerifyCertificates([][]byte{}, tc.Chains)
if tc.ExpectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestNewAPIv4Client(t *testing.T) {
t.Run("should take http proxy into account", func(t *testing.T) {
router := mux.NewRouter()
router.Handle("/api/v4/users/me", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := &model.User{
Id: model.NewId(),
}
err := json.NewEncoder(w).Encode(user)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
}))
s := httptest.NewServer(router)
defer s.Close()
proxyAddr := s.Listener.Addr().String()
_, port, err := net.SplitHostPort(proxyAddr)
require.NoError(t, err)
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("HTTP_PROXY", proxyAddr)
client := NewAPIv4Client("http://somethingelse:"+port, false, false)
_, _, err = client.GetMe(context.Background(), "")
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
var IntegrityCmd = &cobra.Command{
Use: "integrity",
Short: "Check database records integrity.",
Long: "Perform a relational integrity check which returns information about any orphaned record found.",
Args: cobra.NoArgs,
PreRun: localOnlyPrecheck,
RunE: withClient(integrityCmdF),
}
func init() {
IntegrityCmd.Flags().Bool("confirm", false, "Confirm you really want to run a complete integrity check that may temporarily harm system performance")
IntegrityCmd.Flags().BoolP("verbose", "v", false, "Show detailed information on integrity check results")
RootCmd.AddCommand(IntegrityCmd)
}
func printRelationalIntegrityCheckResult(data model.RelationalIntegrityCheckData, verbose bool) {
printer.PrintT("Found {{len .Records}} in relation {{ .ChildName }} orphans of relation {{ .ParentName }}", data)
if !verbose {
return
}
const null = "NULL"
const empty = "empty"
for _, record := range data.Records {
var parentID string
switch {
case record.ParentId == nil:
parentID = null
case *record.ParentId == "":
parentID = empty
default:
parentID = *record.ParentId
}
if record.ChildId != nil {
if parentID == null || parentID == empty {
fmt.Printf(" Child %s (%s.%s) has %s ParentIdAttr (%s.%s)\n", *record.ChildId, data.ChildName, data.ChildIdAttr, parentID, data.ChildName, data.ParentIdAttr)
} else {
fmt.Printf(" Child %s (%s.%s) is missing Parent %s (%s.%s)\n", *record.ChildId, data.ChildName, data.ChildIdAttr, parentID, data.ChildName, data.ParentIdAttr)
}
} else {
if parentID == null || parentID == empty {
fmt.Printf(" Child has %s ParentIdAttr (%s.%s)\n", parentID, data.ChildName, data.ParentIdAttr)
} else {
fmt.Printf(" Child is missing Parent %s (%s.%s)\n", parentID, data.ChildName, data.ParentIdAttr)
}
}
}
}
func printIntegrityCheckResult(result model.IntegrityCheckResult, verbose bool) {
switch data := result.Data.(type) {
case model.RelationalIntegrityCheckData:
printRelationalIntegrityCheckResult(data, verbose)
default:
printer.PrintError("invalid data type")
}
}
func integrityCmdF(c client.Client, command *cobra.Command, args []string) error {
confirmFlag, _ := command.Flags().GetBool("confirm")
if !confirmFlag {
if err := getConfirmation("This check may harm performance on live systems. Are you sure you want to proceed?", false); err != nil {
return err
}
}
verboseFlag, _ := command.Flags().GetBool("verbose")
results, _, err := c.CheckIntegrity(context.TODO())
if err != nil {
return fmt.Errorf("unable to perform integrity check. Error: %w", err)
}
var errs *multierror.Error
for _, result := range results {
if result.Err != nil {
errs = multierror.Append(errs, result.Err)
printer.PrintError(result.Err.Error())
continue
}
printIntegrityCheckResult(result, verboseFlag)
}
return errs.ErrorOrNil()
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestIntegrityCmd() {
s.Run("Integrity check succeeds", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
mockData := model.RelationalIntegrityCheckData{
ParentName: "parent",
ChildName: "child",
ParentIdAttr: "parentIdAttr",
ChildIdAttr: "childIdAttr",
Records: []model.OrphanedRecord{
{
ParentId: new("parentId"),
ChildId: new("childId"),
},
},
}
mockResults := []model.IntegrityCheckResult{
{
Data: mockData,
Err: nil,
},
}
s.client.
EXPECT().
CheckIntegrity(context.TODO()).
Return(mockResults, &model.Response{}, nil).
Times(1)
err := integrityCmdF(s.client, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(mockData, printer.GetLines()[0])
})
s.Run("Integrity check fails", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
s.client.
EXPECT().
CheckIntegrity(context.TODO()).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := integrityCmdF(s.client, cmd, []string{})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal("unable to perform integrity check. Error: mock error", err.Error())
})
s.Run("Integrity check with errors", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
mockData := model.RelationalIntegrityCheckData{
ParentName: "parent",
ChildName: "child",
ParentIdAttr: "parentIdAttr",
ChildIdAttr: "childIdAttr",
Records: []model.OrphanedRecord{
{
ParentId: new("parentId"),
ChildId: new("childId"),
},
},
}
mockResults := []model.IntegrityCheckResult{
{
Data: nil,
Err: errors.New("test error"),
},
{
Data: mockData,
Err: nil,
},
}
s.client.
EXPECT().
CheckIntegrity(context.TODO()).
Return(mockResults, &model.Response{}, nil).
Times(1)
var expected error
expected = multierror.Append(expected, errors.New("test error"))
err := integrityCmdF(s.client, cmd, []string{})
s.Require().EqualError(err, expected.Error())
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(mockData, printer.GetLines()[0])
s.Require().Equal("test error", printer.GetErrorLines()[0])
})
}

View File

@@ -0,0 +1,303 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"time"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var JobCmd = &cobra.Command{
Use: "job",
Short: "Management of jobs",
}
var listJobsCmd = &cobra.Command{
Use: "list",
Short: "List the latest jobs",
Example: ` job list
job list --ids jobID1,jobID2
job list --type ldap_sync --status success
job list --type ldap_sync --status success --page 0 --per-page 10`,
Args: cobra.NoArgs,
RunE: withClient(listJobsCmdF),
}
var createJobCmd = &cobra.Command{
Use: "create [type]",
Short: "Create a new job",
Long: "Create a new job of the given type. Use --data to pass type-specific options as key=value pairs.",
Example: ` job create ldap_sync
job create data_retention
job create message_export --data batch_size=1000`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: jobTypeCompletionF,
RunE: withClient(createJobCmdF),
}
var showJobCmd = &cobra.Command{
Use: "show [job]",
Short: "Show a job",
Example: " job show jobID",
Args: cobra.ExactArgs(1),
ValidArgsFunction: validateArgsWithClient(jobCompletionF),
RunE: withClient(showJobCmdF),
}
var cancelJobCmd = &cobra.Command{
Use: "cancel [job]",
Short: "Cancel a job",
Example: " job cancel jobID",
Args: cobra.ExactArgs(1),
ValidArgsFunction: validateArgsWithClient(jobCompletionF),
RunE: withClient(cancelJobCmdF),
}
var updateJobCmd = &cobra.Command{
Use: "update [job] [status]",
Short: "Update the status of a job",
Long: `Update the status of a job. The following restrictions are in place:
- in_progress -> pending
- in_progress | pending -> cancel_requested
- cancel_requested -> canceled
Those restriction can be bypassed with --force=true but the only statuses you can go to are: pending, cancel_requested and canceled. This can have unexpected consequences and should be used with caution.`,
Example: ` job update myJobID pending
job update myJobID pending --force true
job update myJobID canceled --force true`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(updateJobCmdF),
}
func init() {
listJobsCmd.Flags().Int("page", 0, "Page number to fetch for the list of jobs")
listJobsCmd.Flags().Int("per-page", 5, "Number of jobs to be fetched")
listJobsCmd.Flags().Bool("all", false, "Fetch all jobs. --page flag will be ignored if provided")
listJobsCmd.Flags().StringSlice("ids", nil, "Comma-separated list of job IDs to which the operation will be applied. All other flags are ignored")
listJobsCmd.Flags().String("status", "", "Filter by job status")
listJobsCmd.Flags().String("type", "", "Filter by job type")
createJobCmd.Flags().StringToString("data", nil, "Comma-separated list of key=value pairs passed to the job as type-specific options")
updateJobCmd.Flags().Bool("force", false, "Setting a job status is restricted to certain statuses. You can overwrite these restrictions by using --force. This might cause unexpected behaviour on your Mattermost Server. Use this option with caution.")
JobCmd.AddCommand(
listJobsCmd,
createJobCmd,
showJobCmd,
cancelJobCmd,
updateJobCmd,
)
RootCmd.AddCommand(JobCmd)
}
func listJobsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
ids, err := cmd.Flags().GetStringSlice("ids")
if err != nil {
return err
}
jobType, err := cmd.Flags().GetString("type")
if err != nil {
return err
}
status, err := cmd.Flags().GetString("status")
if err != nil {
return err
}
if len(ids) > 0 {
jobs := make([]*model.Job, 0, len(ids))
var result *multierror.Error
for _, id := range ids {
isValidId := model.IsValidId(id)
if !isValidId {
result = multierror.Append(result, fmt.Errorf("invalid job ID: %s", id))
continue
}
job, _, err := c.GetJob(context.TODO(), id)
if err != nil {
result = multierror.Append(result, err)
continue
}
jobs = append(jobs, job)
}
for _, job := range jobs {
printJob(job)
}
return result.ErrorOrNil()
}
return jobListCmdF(c, cmd, jobType, status)
}
func updateJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
force, err := cmd.Flags().GetBool("force")
if err != nil {
return err
}
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
status := args[1]
if !model.IsValidJobStatus(status) {
return fmt.Errorf("invalid job status: %s", status)
}
_, err = c.UpdateJobStatus(context.TODO(), jobId, status, force)
if err != nil {
return err
}
return nil
}
func createJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobType := args[0]
data, err := cmd.Flags().GetStringToString("data")
if err != nil {
return err
}
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: jobType,
Data: data,
})
if err != nil {
return fmt.Errorf("failed to create job: %w", err)
}
printer.PrintT("Job successfully created, ID: {{.Id}}", job)
return nil
}
func showJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
job, _, err := c.GetJob(context.TODO(), jobId)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
printJob(job)
return nil
}
func cancelJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
if _, err := c.CancelJob(context.TODO(), jobId); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
return nil
}
func jobTypeCompletionF(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return model.AllJobTypes[:], cobra.ShellCompDirectiveNoFileComp
}
func jobCompletionF(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return fetchAndComplete(
func(ctx context.Context, c client.Client, page, perPage int) ([]*model.Job, *model.Response, error) {
return c.GetJobs(ctx, "", "", page, perPage)
},
func(j *model.Job) []string { return []string{j.Id} },
)(ctx, c, cmd, args, toComplete)
}
func jobListCmdF(c client.Client, command *cobra.Command, jobType string, status string) error {
page, err := command.Flags().GetInt("page")
if err != nil {
return err
}
perPage, err := command.Flags().GetInt("per-page")
if err != nil {
return err
}
showAll, err := command.Flags().GetBool("all")
if err != nil {
return err
}
if showAll {
page = 0
}
if jobType != "" && !model.IsValidJobType(jobType) {
return fmt.Errorf("invalid job type: %s", jobType)
}
if status != "" && !model.IsValidJobStatus(status) {
return fmt.Errorf("invalid job status: %s", status)
}
for {
jobs, _, err := c.GetJobs(context.TODO(), jobType, status, page, perPage)
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if len(jobs) == 0 {
if !showAll || page == 0 {
printer.Print("No jobs found")
}
return nil
}
for _, job := range jobs {
printJob(job)
}
if !showAll {
break
}
page++
}
return nil
}
func printJob(job *model.Job) {
if job.StartAt > 0 {
printer.PrintT(fmt.Sprintf(` ID: {{.Id}}
Type: {{.Type}}
Status: {{.Status}}
Created: %s
Started: %s
Data: {{.Data}}
`,
time.Unix(job.CreateAt/1000, 0), time.Unix(job.StartAt/1000, 0)), job)
} else {
printer.PrintT(fmt.Sprintf(` ID: {{.Id}}
Status: {{.Status}}
Created: %s
`,
time.Unix(job.CreateAt/1000, 0)), job)
}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestCreateJobCmdF() {
s.SetupTestHelper().InitBasic(s.T())
newCmd := func() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringToString("data", nil, "")
return cmd
}
s.Run("create a job without permissions", func() {
printer.Clean()
err := createJobCmdF(s.th.Client, newCmd(), []string{model.JobTypeExportProcess})
s.Require().EqualError(err, "failed to create job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("create a job", func(c client.Client) {
printer.Clean()
err := createJobCmdF(c, newCmd(), []string{model.JobTypeExportProcess})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(model.JobTypeExportProcess, printer.GetLines()[0].(*model.Job).Type)
})
s.RunForSystemAdminAndLocal("create a job with data", func(c client.Client) {
printer.Clean()
cmd := newCmd()
err := cmd.Flags().Set("data", "include_attachments=true")
s.Require().NoError(err)
err = createJobCmdF(c, cmd, []string{model.JobTypeExportProcess})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("true", printer.GetLines()[0].(*model.Job).Data["include_attachments"])
})
}
func (s *MmctlE2ETestSuite) TestShowJobCmdF() {
s.SetupTestHelper().InitBasic(s.T())
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
s.Run("show a job without permissions", func() {
printer.Clean()
err := showJobCmdF(s.th.Client, &cobra.Command{}, []string{job.Id})
s.Require().EqualError(err, "failed to get job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("show a job that does not exist", func(c client.Client) {
printer.Clean()
err := showJobCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().ErrorContains(err, "failed to get job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("show a job", func(c client.Client) {
printer.Clean()
err := showJobCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job, printer.GetLines()[0].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestCancelJobCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("cancel a job without permissions", func() {
printer.Clean()
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
err := cancelJobCmdF(s.th.Client, &cobra.Command{}, []string{job.Id})
s.Require().EqualError(err, "failed to cancel job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("cancel a job that does not exist", func(c client.Client) {
printer.Clean()
err := cancelJobCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().ErrorContains(err, "failed to cancel job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("cancel a job", func(c client.Client) {
printer.Clean()
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeExportProcess,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
err := cancelJobCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
// Refresh the job to confirm it was canceled.
job, appErr = s.th.App.GetJob(s.th.Context, job.Id)
s.Require().Nil(appErr)
s.Require().Equal(model.JobStatusCanceled, job.Status)
})
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestListJobsCmdF() {
s.Run("no jobs found", func() {
printer.Clean()
var mockJobs []*model.Job
cmd := &cobra.Command{}
perPage := 10
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
cmd.Flags().StringSlice("ids", []string{}, "")
cmd.Flags().String("status", "", "")
cmd.Flags().String("type", "", "")
s.client.
EXPECT().
GetJobs(context.TODO(), "", "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := listJobsCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.Run("3 jobs found", func() {
printer.Clean()
mockJobs := []*model.Job{
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
}
cmd := &cobra.Command{}
perPage := 3
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
cmd.Flags().StringSlice("ids", []string{}, "")
cmd.Flags().String("status", "", "")
cmd.Flags().String("type", "", "")
s.client.
EXPECT().
GetJobs(context.TODO(), "", "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := listJobsCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockJobs))
s.Empty(printer.GetErrorLines())
for i, line := range printer.GetLines() {
s.Equal(mockJobs[i], line.(*model.Job))
}
})
s.Run("return 1 job using ids flag", func() {
printer.Clean()
id := model.NewId()
mockJob := &model.Job{
Id: id,
}
cmd := &cobra.Command{}
perPage := 3
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
cmd.Flags().StringSlice("ids", []string{id}, "")
cmd.Flags().String("status", "", "")
cmd.Flags().String("type", "", "")
s.client.
EXPECT().
GetJob(context.TODO(), id).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := listJobsCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
for _, line := range printer.GetLines() {
s.Equal(mockJob, line.(*model.Job))
}
})
s.Run("return 2 jobs by status", func() {
printer.Clean()
mockJobs := []*model.Job{
{
Id: model.NewId(),
Status: model.JobStatusSuccess,
},
{
Id: model.NewId(),
Status: model.JobStatusSuccess,
},
}
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
cmd.Flags().String("status", model.JobStatusSuccess, "")
cmd.Flags().StringSlice("ids", []string{}, "")
cmd.Flags().String("type", "", "")
s.client.
EXPECT().
GetJobs(context.TODO(), "", model.JobStatusSuccess, 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := listJobsCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockJobs))
s.Empty(printer.GetErrorLines())
for i, line := range printer.GetLines() {
s.Equal(mockJobs[i], line.(*model.Job))
}
})
s.Run("return 2 jobs by type", func() {
printer.Clean()
mockJobs := []*model.Job{
{
Id: model.NewId(),
Type: model.JobTypeDataRetention,
},
{
Id: model.NewId(),
Type: model.JobTypeDataRetention,
},
}
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
cmd.Flags().String("type", model.JobTypeDataRetention, "")
cmd.Flags().StringSlice("ids", []string{}, "")
cmd.Flags().String("status", "", "")
s.client.
EXPECT().
GetJobs(context.TODO(), model.JobTypeDataRetention, "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := listJobsCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockJobs))
s.Empty(printer.GetErrorLines())
for i, line := range printer.GetLines() {
s.Equal(mockJobs[i], line.(*model.Job))
}
})
}
func (s *MmctlUnitTestSuite) TestUpdateJobCmdF() {
s.Run("update job status", func() {
printer.Clean()
id := model.NewId()
cmd := &cobra.Command{}
cmd.Flags().Bool("force", true, "")
s.client.
EXPECT().
UpdateJobStatus(context.TODO(), id, model.JobStatusPending, true).
Return(&model.Response{}, nil).
Times(1)
err := updateJobCmdF(s.client, cmd, []string{id, model.JobStatusPending})
s.Require().Nil(err)
})
}
func (s *MmctlUnitTestSuite) TestCreateJobCmdF() {
s.Run("create job with data", func() {
printer.Clean()
data := map[string]string{"batch_size": "1000"}
mockJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeMessageExport,
Data: data,
}
cmd := &cobra.Command{}
cmd.Flags().StringToString("data", nil, "")
err := cmd.Flags().Set("data", "batch_size=1000")
s.Require().NoError(err)
s.client.
EXPECT().
CreateJob(context.TODO(), &model.Job{Type: model.JobTypeMessageExport, Data: data}).
Return(mockJob, &model.Response{}, nil).
Times(1)
err = createJobCmdF(s.client, cmd, []string{model.JobTypeMessageExport})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("forwards type not in AllJobTypes to the server", func() {
printer.Clean()
mockJob := &model.Job{Id: model.NewId(), Type: model.JobTypeAccessControlSync}
cmd := &cobra.Command{}
cmd.Flags().StringToString("data", nil, "")
s.client.
EXPECT().
CreateJob(context.TODO(), &model.Job{Type: model.JobTypeAccessControlSync, Data: map[string]string{}}).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := createJobCmdF(s.client, cmd, []string{model.JobTypeAccessControlSync})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
})
s.Run("create job returns server error", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().StringToString("data", nil, "")
s.client.
EXPECT().
CreateJob(context.TODO(), &model.Job{Type: model.JobTypeLdapSync, Data: map[string]string{}}).
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
err := createJobCmdF(s.client, cmd, []string{model.JobTypeLdapSync})
s.Require().NotNil(err)
s.Empty(printer.GetLines())
})
}
func (s *MmctlUnitTestSuite) TestJobTypeCompletionF() {
s.Run("suggests all job types for the first argument", func() {
suggestions, directive := jobTypeCompletionF(&cobra.Command{}, []string{}, "")
s.Equal(cobra.ShellCompDirectiveNoFileComp, directive)
s.ElementsMatch(model.AllJobTypes[:], suggestions)
})
s.Run("suggests nothing once the type is provided", func() {
suggestions, directive := jobTypeCompletionF(&cobra.Command{}, []string{model.JobTypeLdapSync}, "")
s.Equal(cobra.ShellCompDirectiveNoFileComp, directive)
s.Empty(suggestions)
})
}
func (s *MmctlUnitTestSuite) TestShowJobCmdF() {
s.Run("show job", func() {
printer.Clean()
id := model.NewId()
mockJob := &model.Job{Id: id}
s.client.
EXPECT().
GetJob(context.TODO(), id).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := showJobCmdF(s.client, &cobra.Command{}, []string{id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("show job with invalid ID", func() {
printer.Clean()
err := showJobCmdF(s.client, &cobra.Command{}, []string{"invalid-id"})
s.Require().NotNil(err)
s.Empty(printer.GetLines())
})
}
func (s *MmctlUnitTestSuite) TestCancelJobCmdF() {
s.Run("cancel job", func() {
printer.Clean()
id := model.NewId()
s.client.
EXPECT().
CancelJob(context.TODO(), id).
Return(&model.Response{}, nil).
Times(1)
err := cancelJobCmdF(s.client, &cobra.Command{}, []string{id})
s.Require().Nil(err)
s.Empty(printer.GetErrorLines())
})
s.Run("cancel job with invalid ID", func() {
printer.Clean()
err := cancelJobCmdF(s.client, &cobra.Command{}, []string{"invalid-id"})
s.Require().NotNil(err)
})
s.Run("cancel job returns server error", func() {
printer.Clean()
id := model.NewId()
s.client.
EXPECT().
CancelJob(context.TODO(), id).
Return(&model.Response{}, errors.New("some-error")).
Times(1)
err := cancelJobCmdF(s.client, &cobra.Command{}, []string{id})
s.Require().NotNil(err)
})
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
var LdapCmd = &cobra.Command{
Use: "ldap",
Short: "LDAP related utilities",
}
func newLDAPSyncCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sync",
Short: "Synchronize now",
Long: "Synchronize all LDAP users and groups now.",
Example: "mmctl ldap sync",
RunE: withClient(ldapSyncCmdF),
}
return cmd
}
var LdapIDMigrate = &cobra.Command{
Use: "idmigrate <objectGUID>",
Short: "Migrate LDAP IdAttribute to new value",
Long: `Migrate LDAP "IdAttribute" to a new value. Run this utility to change the value of your ID Attribute without your users losing their accounts. After running the command you can change the ID Attribute to the new value in the System Console. For example, if your current ID Attribute was "sAMAccountName" and you wanted to change it to "objectGUID", you would:
1. Wait for an off-peak time when your users won’t be impacted by a server restart.
2. Run the command "mmctl ldap idmigrate objectGUID".
3. Update the config within the System Console to the new value "objectGUID".
4. Restart the Mattermost server.`,
Example: "mmctl ldap idmigrate objectGUID",
Args: cobra.ExactArgs(1),
RunE: withClient(ldapIDMigrateCmdF),
}
var LdapJobCmd = &cobra.Command{
Use: "job",
Short: "List and show LDAP sync jobs",
}
var LdapJobListCmd = &cobra.Command{
Use: "list",
Example: "mmctl ldap job list",
Short: "List LDAP sync jobs",
// Alisases cause error in zsh. Supposedly, completion V2 will fix that: https://github.com/spf13/cobra/pull/1146
// https://mattermost.atlassian.net/browse/MM-57062
// Aliases: []string{"ls"},
Args: cobra.NoArgs,
ValidArgsFunction: noCompletion,
RunE: withClient(ldapJobListCmdF),
}
var LdapJobShowCmd = &cobra.Command{
Use: "show [ldapJobID]",
Example: "mmctl ldap show f3d68qkkm7n8xgsfxwuo498rah",
Short: "Show LDAP sync job",
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: validateArgsWithClient(ldapJobShowCompletionF),
RunE: withClient(ldapJobShowCmdF),
}
func init() {
ldapSyncCmd := newLDAPSyncCmd()
LdapJobListCmd.Flags().Int("page", 0, "Page number to fetch for the list of import jobs")
LdapJobListCmd.Flags().Int("per-page", 200, "Number of import jobs to be fetched")
LdapJobListCmd.Flags().Bool("all", false, "Fetch all import jobs. --page flag will be ignore if provided")
LdapJobCmd.AddCommand(
LdapJobListCmd,
LdapJobShowCmd,
)
LdapCmd.AddCommand(
ldapSyncCmd,
LdapIDMigrate,
LdapJobCmd,
)
RootCmd.AddCommand(LdapCmd)
}
func ldapSyncCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
resp, err := c.SyncLdap(context.TODO())
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "ok"})
} else {
printer.PrintT("Status: {{.status}}", map[string]any{"status": "error"})
}
return nil
}
func ldapIDMigrateCmdF(c client.Client, cmd *cobra.Command, args []string) error {
toAttribute := args[0]
resp, err := c.MigrateIdLdap(context.TODO(), toAttribute)
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
printer.Print("AD/LDAP IdAttribute migration complete. You can now change your IdAttribute to: " + toAttribute)
}
return nil
}
func ldapJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
return jobListCmdF(c, command, model.JobTypeLdapSync, "")
}
func ldapJobShowCmdF(c client.Client, command *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("expected at least one argument (ldapJobID). See help text for details")
}
job, _, err := c.GetJob(context.TODO(), args[0])
if err != nil {
return fmt.Errorf("failed to get LDAP sync job: %w", err)
}
printJob(job)
return nil
}
func ldapJobShowCompletionF(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return fetchAndComplete(
func(ctx context.Context, c client.Client, page, perPage int) ([]*model.Job, *model.Response, error) {
return c.GetJobsByType(ctx, model.JobTypeLdapSync, page, perPage)
},
func(t *model.Job) []string { return []string{t.Id} },
)(ctx, c, cmd, args, toComplete)
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func configForLdap(th *api4.TestHelper) {
ldapHost := os.Getenv("CI_LDAP_HOST")
if ldapHost == "" {
ldapHost = testutils.GetInterface(*th.App.Config().LdapSettings.LdapPort)
}
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
*cfg.LdapSettings.Enable = true
*cfg.LdapSettings.EnableSync = true
*cfg.LdapSettings.LdapServer = "dockerhost"
*cfg.LdapSettings.BaseDN = "dc=mm,dc=test,dc=com"
*cfg.LdapSettings.LdapServer = ldapHost
*cfg.LdapSettings.BindUsername = "cn=admin,dc=mm,dc=test,dc=com"
*cfg.LdapSettings.BindPassword = "mostest_password"
*cfg.LdapSettings.FirstNameAttribute = "cn"
*cfg.LdapSettings.LastNameAttribute = "sn"
*cfg.LdapSettings.NicknameAttribute = "cn"
*cfg.LdapSettings.EmailAttribute = "mail"
*cfg.LdapSettings.UsernameAttribute = "uid"
*cfg.LdapSettings.IdAttribute = "cn"
*cfg.LdapSettings.LoginIdAttribute = "uid"
*cfg.LdapSettings.SkipCertificateVerification = true
*cfg.LdapSettings.GroupFilter = ""
*cfg.LdapSettings.GroupDisplayNameAttribute = "cN"
*cfg.LdapSettings.GroupIdAttribute = "entRyUuId"
*cfg.LdapSettings.MaxPageSize = 0
})
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
}
func (s *MmctlE2ETestSuite) TestLdapSyncCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
configForLdap(s.th)
s.Run("MM-T3971 Should not allow regular user to start LDAP sync job", func() {
printer.Clean()
err := ldapSyncCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T2529 Should start LDAP sync job", func(c client.Client) {
printer.Clean()
jobs, appErr := s.th.App.GetJobsByTypePage(s.th.Context, model.JobTypeLdapSync, 0, 100)
s.Require().Nil(appErr)
initialNumJobs := len(jobs)
err := ldapSyncCmdF(c, &cobra.Command{}, nil)
s.Require().NoError(err)
s.Require().NotEmpty(printer.GetLines())
s.Require().Equal(printer.GetLines()[0], map[string]any{"status": "ok"})
s.Require().Len(printer.GetErrorLines(), 0)
// we need to wait a bit for job creation
time.Sleep(time.Second)
jobs, appErr = s.th.App.GetJobsByTypePage(s.th.Context, model.JobTypeLdapSync, 0, 100)
s.Require().Nil(appErr)
s.Require().NotEmpty(jobs)
s.Assert().Equal(initialNumJobs+1, len(jobs))
})
}
func (s *MmctlE2ETestSuite) TestLdapIDMigrateCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
configForLdap(s.th)
s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.LdapSettings.IdAttribute = "uid" })
// use existing ldap user from the test-data.ldif script
// dn: uid=dev.one,ou=testusers,dc=mm,dc=test,dc=com
// cn: Dev1
// userPassword: Password1
ldapUser, appErr := s.th.App.AuthenticateUserForLogin(s.th.Context, "", "dev.one", "Password1", "", "", true)
s.Require().Nil(appErr)
s.Require().NotNil(ldapUser)
s.Require().Equal(model.UserAuthServiceLdap, ldapUser.AuthService)
s.Require().Equal("dev.one", *ldapUser.AuthData)
s.Run("MM-T3973 Should not allow regular user to migrate LDAP ID attribute", func() {
printer.Clean()
err := ldapIDMigrateCmdF(s.th.Client, &cobra.Command{}, []string{"objectGUID"})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3972 Should migrate LDAP ID attribute", func(c client.Client) {
printer.Clean()
err := ldapIDMigrateCmdF(c, &cobra.Command{}, []string{"cn"})
s.Require().NoError(err)
defer func() {
s.Require().Nil(s.th.App.MigrateIdLDAP(s.th.Context, "uid"))
}()
s.Require().NotEmpty(printer.GetLines())
s.Require().Equal(printer.GetLines()[0], "AD/LDAP IdAttribute migration complete. You can now change your IdAttribute to: "+"cn")
s.Require().Len(printer.GetErrorLines(), 0)
updatedUser, appErr := s.th.App.GetUser(s.th.Context, ldapUser.Id)
s.Require().Nil(appErr)
s.Require().Equal("Dev1", *updatedUser.AuthData)
})
}
func (s *MmctlE2ETestSuite) TestLdapJobListCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
configForLdap(s.th)
s.Run("Should not allow regular user to list LDAP groups", func() {
printer.Clean()
err := ldapJobListCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("No LDAP jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().Bool("all", false, "")
err := ldapJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.RunForSystemAdminAndLocal("get some LDAP sync jobs", func(c client.Client) {
printer.Clean()
cmd := &cobra.Command{}
perPage := 2
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
_, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeLdapSync,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job2, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeLdapSync,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
job3, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeLdapSync,
})
s.Require().Nil(appErr)
err := ldapJobListCmdF(c, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), perPage)
s.Require().Empty(printer.GetErrorLines())
s.Require().Equal(job3, printer.GetLines()[0].(*model.Job))
s.Require().Equal(job2, printer.GetLines()[1].(*model.Job))
})
}
func (s *MmctlE2ETestSuite) TestLdapJobShowCmdF() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
configForLdap(s.th)
job, appErr := s.th.App.CreateJob(s.th.Context, &model.Job{
Type: model.JobTypeLdapSync,
})
s.Require().Nil(appErr)
time.Sleep(time.Millisecond)
s.Run("no permissions", func() {
printer.Clean()
err := ldapJobShowCmdF(s.th.Client, &cobra.Command{}, []string{job.Id})
s.Require().EqualError(err, "failed to get LDAP sync job: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("no args", func(c client.Client) {
printer.Clean()
err := ldapJobShowCmdF(c, &cobra.Command{}, []string{})
s.Require().EqualError(err, "expected at least one argument (ldapJobID). See help text for details")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("not found", func(c client.Client) {
printer.Clean()
err := ldapJobShowCmdF(c, &cobra.Command{}, []string{model.NewId()})
s.Require().ErrorContains(err, "failed to get LDAP sync job: Unable to get the job.")
s.Require().Empty(printer.GetLines())
s.Require().Empty(printer.GetErrorLines())
})
s.RunForSystemAdminAndLocal("found", func(c client.Client) {
printer.Clean()
err := ldapJobShowCmdF(c, &cobra.Command{}, []string{job.Id})
s.Require().Nil(err)
s.Require().Empty(printer.GetErrorLines())
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(job, printer.GetLines()[0].(*model.Job))
})
}

View File

@@ -0,0 +1,255 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlUnitTestSuite) TestLdapSyncCmd() {
s.Run("Sync without errors", func() {
printer.Clean()
outputMessage := map[string]any{"status": "ok"}
s.client.
EXPECT().
SyncLdap(context.TODO()).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := ldapSyncCmdF(s.client, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], outputMessage)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Not able to Sync", func() {
printer.Clean()
outputMessage := map[string]any{"status": "error"}
s.client.
EXPECT().
SyncLdap(context.TODO()).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
err := ldapSyncCmdF(s.client, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], outputMessage)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Sync with response error", func() {
printer.Clean()
mockError := errors.New("mock error")
s.client.
EXPECT().
SyncLdap(context.TODO()).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
err := ldapSyncCmdF(s.client, &cobra.Command{}, []string{})
s.Require().NotNil(err)
s.Require().Equal(err, mockError)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestLdapMigrateID() {
s.Run("Run successfully without errors", func() {
printer.Clean()
s.client.
EXPECT().
MigrateIdLdap(context.TODO(), "test-id").
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := ldapIDMigrateCmdF(s.client, &cobra.Command{}, []string{"test-id"})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Contains(printer.GetLines()[0], "test-id")
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Unable to migrate", func() {
printer.Clean()
s.client.
EXPECT().
MigrateIdLdap(context.TODO(), "test-id").
Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("test-error")).
Times(1)
err := ldapIDMigrateCmdF(s.client, &cobra.Command{}, []string{"test-id"})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestLdapJobListCmdF() {
s.Run("no LDAP jobs", func() {
printer.Clean()
var mockJobs []*model.Job
cmd := &cobra.Command{}
perPage := 10
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetJobs(context.TODO(), model.JobTypeLdapSync, "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := ldapJobListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal("No jobs found", printer.GetLines()[0])
})
s.Run("some LDAP jobs", func() {
printer.Clean()
mockJobs := []*model.Job{
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
{
Id: model.NewId(),
},
}
cmd := &cobra.Command{}
perPage := 3
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", perPage, "")
cmd.Flags().Bool("all", false, "")
s.client.
EXPECT().
GetJobs(context.TODO(), model.JobTypeLdapSync, "", 0, perPage).
Return(mockJobs, &model.Response{}, nil).
Times(1)
err := ldapJobListCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Len(printer.GetLines(), len(mockJobs))
s.Empty(printer.GetErrorLines())
for i, line := range printer.GetLines() {
s.Equal(mockJobs[i], line.(*model.Job))
}
})
}
func (s *MmctlUnitTestSuite) TestLdapJobShowCmdF() {
s.Run("not found", func() {
printer.Clean()
jobID := model.NewId()
s.client.
EXPECT().
GetJob(context.TODO(), jobID).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("not found")).
Times(1)
err := ldapJobShowCmdF(s.client, &cobra.Command{}, []string{jobID})
s.Require().NotNil(err)
s.Empty(printer.GetLines())
s.Empty(printer.GetErrorLines())
})
s.Run("found", func() {
printer.Clean()
mockJob := &model.Job{
Id: model.NewId(),
}
s.client.
EXPECT().
GetJob(context.TODO(), mockJob.Id).
Return(mockJob, &model.Response{}, nil).
Times(1)
err := ldapJobShowCmdF(s.client, &cobra.Command{}, []string{mockJob.Id})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Empty(printer.GetErrorLines())
s.Equal(mockJob, printer.GetLines()[0].(*model.Job))
})
s.Run("shell completion", func() {
s.Run("no match for empty argument", func() {
r, dir := ldapJobShowCompletionF(context.Background(), s.client, nil, nil, "")
s.Equal(cobra.ShellCompDirectiveNoFileComp, dir)
s.Equal([]string{}, r)
})
s.Run("one element matches", func() {
mockJobs := []*model.Job{
{
Id: "0_id",
},
{
Id: "1_id",
},
{
Id: "2_id",
},
}
s.client.
EXPECT().
GetJobsByType(context.Background(), model.JobTypeLdapSync, 0, DefaultPageSize).
Return(mockJobs, &model.Response{}, nil).
Times(1)
r, dir := ldapJobShowCompletionF(context.Background(), s.client, nil, nil, "1")
s.Equal(cobra.ShellCompDirectiveNoFileComp, dir)
s.Equal([]string{"1_id"}, r)
})
s.Run("more elements then the limit match", func() {
var mockJobs []*model.Job
for i := range 100 {
mockJobs = append(mockJobs, &model.Job{
Id: fmt.Sprintf("id_%d", i),
})
}
var expected []string
for i := range shellCompletionMaxItems {
expected = append(expected, fmt.Sprintf("id_%d", i))
}
s.client.
EXPECT().
GetJobsByType(context.Background(), model.JobTypeLdapSync, 0, DefaultPageSize).
Return(mockJobs, &model.Response{}, nil).
Times(1)
r, dir := ldapJobShowCompletionF(context.Background(), s.client, nil, nil, "id_")
s.Equal(cobra.ShellCompDirectiveNoFileComp, dir)
s.Equal(expected, r)
})
})
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"os"
"strconv"
"time"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var LicenseCmd = &cobra.Command{
Use: "license",
Short: "Licensing commands",
}
var UploadLicenseCmd = &cobra.Command{
Use: "upload [license]",
Short: "Upload a license.",
Long: "Upload a license. Replaces current license.",
Example: " license upload /path/to/license/mylicensefile.mattermost-license",
RunE: withClient(uploadLicenseCmdF),
}
var UploadLicenseStringCmd = &cobra.Command{
Use: "upload-string [license]",
Short: "Upload a license from a string.",
Long: "Upload a license from a string. Replaces current license.",
Example: " license upload-string \"mylicensestring\"",
RunE: withClient(uploadLicenseStringCmdF),
}
var RemoveLicenseCmd = &cobra.Command{
Use: "remove",
Short: "Remove the current license.",
Long: "Remove the current license and leave mattermost in Team Edition.",
Example: " license remove",
RunE: withClient(removeLicenseCmdF),
}
var GetLicenseCmd = &cobra.Command{
Use: "get",
Short: "Get the current license.",
Long: "Get the current server license and print it.",
Example: " license get",
RunE: withClient(getLicenseCmdF),
}
func init() {
LicenseCmd.AddCommand(UploadLicenseCmd)
LicenseCmd.AddCommand(RemoveLicenseCmd)
LicenseCmd.AddCommand(UploadLicenseStringCmd)
LicenseCmd.AddCommand(GetLicenseCmd)
RootCmd.AddCommand(LicenseCmd)
}
func uploadLicenseStringCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("enter one license file to upload")
}
licenseBytes := []byte(args[0])
if _, err := c.UploadLicenseFile(context.TODO(), licenseBytes); err != nil {
return err
}
printer.Print("Uploaded license file")
return nil
}
func uploadLicenseCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("enter one license file to upload")
}
fileBytes, err := os.ReadFile(args[0])
if err != nil {
return err
}
if _, err := c.UploadLicenseFile(context.TODO(), fileBytes); err != nil {
return err
}
printer.Print("Uploaded license file")
return nil
}
func removeLicenseCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if _, err := c.RemoveLicenseFile(context.TODO()); err != nil {
return err
}
printer.Print("Removed license")
return nil
}
func getLicenseCmdF(c client.Client, cmd *cobra.Command, args []string) error {
license, _, err := c.GetOldClientLicense(context.TODO(), "")
if err != nil {
return err
}
if license["IsLicensed"] != "true" {
printer.Print("No license installed")
return nil
}
// Format timestamps for human-readable output
license["StartsAtReadable"] = formatLicenseTimestamp(license["StartsAt"])
license["ExpiresAtReadable"] = formatLicenseTimestamp(license["ExpiresAt"])
license["IssuedAtReadable"] = formatLicenseTimestamp(license["IssuedAt"])
printer.PrintT(`License ID: {{.Id}}
Starts At: {{.StartsAtReadable}}
Expires At: {{.ExpiresAtReadable}}
Users: {{.Users}}
SKU: {{.SkuShortName}}
Is Trial: {{.IsTrial}}
Issued At: {{.IssuedAtReadable}}
Name: {{.Name}}
Company: {{.Company}}`, license)
return nil
}
// formatLicenseTimestamp converts an epoch milliseconds string to a human-readable date.
func formatLicenseTimestamp(epochMs string) string {
ms, err := strconv.ParseInt(epochMs, 10, 64)
if err != nil {
return epochMs
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"encoding/json"
"os"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestRemoveLicenseCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.Require().True(s.th.App.Srv().SetLicense(model.NewTestLicense()))
s.Run("MM-T3955 Should fail when regular user attempts to remove the server license", func() {
printer.Clean()
err := removeLicenseCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3954 Should be able to remove the server license", func(c client.Client) {
printer.Clean()
err := removeLicenseCmdF(c, &cobra.Command{}, nil)
s.Require().NoError(err)
defer func() {
s.Require().True(s.th.App.Srv().SetLicense(model.NewTestLicense()))
}()
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Removed license")
s.Require().Len(printer.GetErrorLines(), 0)
license := s.th.App.Srv().License()
if model.BuildEnterpriseReady == "true" {
// If an enterprise build is used,
// the server resets to an Entry License.
s.Require().NotNil(license)
s.Require().True(license.IsMattermostEntry())
} else {
s.Require().Nil(license)
}
})
}
func (s *MmctlE2ETestSuite) TestUploadLicenseCmdF() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
// create temporary file
tmpFile, err := os.CreateTemp(os.TempDir(), "testLicense-")
s.Require().NoError(err)
license := model.NewTestLicense()
b, err := json.Marshal(license)
s.Require().NoError(err)
_, err = tmpFile.Write(b)
s.Require().NoError(err)
s.T().Cleanup(func() {
os.Remove(tmpFile.Name())
})
s.Run("MM-T3953 Should fail when regular user attempts to upload a license file", func() {
printer.Clean()
err := uploadLicenseCmdF(s.th.Client, &cobra.Command{}, []string{tmpFile.Name()})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3952 Should be able to upload a license file, fail on validation", func(c client.Client) {
printer.Clean()
err := uploadLicenseCmdF(c, &cobra.Command{}, []string{tmpFile.Name()})
s.Require().Error(err)
appErr, ok := err.(*model.AppError)
s.Require().True(ok)
s.Require().Equal(appErr.Message, "Invalid license file.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"net/http"
"os"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
const (
fakeLicensePayload = "This is the license."
)
func (s *MmctlUnitTestSuite) TestRemoveLicenseCmd() {
s.Run("Remove license successfully", func() {
printer.Clean()
s.client.
EXPECT().
RemoveLicenseFile(context.TODO()).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
err := removeLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(printer.GetLines()[0], "Removed license")
})
s.Run("Fail to remove license", func() {
printer.Clean()
mockErr := errors.New("mock error")
s.client.
EXPECT().
RemoveLicenseFile(context.TODO()).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr).
Times(1)
err := removeLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(err, mockErr)
})
}
func (s *MmctlUnitTestSuite) TestUploadLicenseCmdF() {
// create temporary file
tmpFile, err := os.CreateTemp(os.TempDir(), "testLicense-")
s.NoError(err)
text := []byte(fakeLicensePayload)
_, err = tmpFile.Write(text)
s.NoError(err)
defer os.Remove(tmpFile.Name())
mockLicenseFile := []byte(fakeLicensePayload)
s.Run("Upload license successfully", func() {
printer.Clean()
s.client.
EXPECT().
UploadLicenseFile(context.TODO(), mockLicenseFile).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := uploadLicenseCmdF(s.client, &cobra.Command{}, []string{tmpFile.Name()})
s.Require().Nil(err)
})
s.Run("Fail to upload license if file not found", func() {
printer.Clean()
path := "/path/to/nonexistentfile"
errMsg := "open " + path + ": no such file or directory"
s.client.
EXPECT().
UploadLicenseFile(context.TODO(), mockLicenseFile).
Times(0)
err := uploadLicenseCmdF(s.client, &cobra.Command{}, []string{path})
s.Require().EqualError(err, errMsg)
})
s.Run("Fail to upload license if no path is given", func() {
printer.Clean()
err := uploadLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().EqualError(err, "enter one license file to upload")
})
}
func (s *MmctlUnitTestSuite) TestUploadLicenseStringCmdF() {
// create temporary file
licenseString := string(fakeLicensePayload)
mockLicenseFile := []byte(fakeLicensePayload)
s.Run("Upload license successfully", func() {
printer.Clean()
s.client.
EXPECT().
UploadLicenseFile(context.TODO(), mockLicenseFile).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := uploadLicenseStringCmdF(s.client, &cobra.Command{}, []string{licenseString})
s.Require().Nil(err)
})
s.Run("Fail to upload license if no license string is given", func() {
printer.Clean()
err := uploadLicenseStringCmdF(s.client, &cobra.Command{}, []string{})
s.Require().EqualError(err, "enter one license file to upload")
})
}
func (s *MmctlUnitTestSuite) TestGetLicenseCmdF() {
s.Run("Get license successfully", func() {
printer.Clean()
mockLicense := map[string]string{
"IsLicensed": "true",
"Id": "test-license-id",
"Company": "Test Company",
"Name": "Test Contact",
"Email": "test@example.com",
"SkuShortName": "enterprise",
"Users": "100",
"IssuedAt": "1609459200000",
"StartsAt": "1609459200000",
"ExpiresAt": "1640995200000",
"IsTrial": "false",
}
s.client.
EXPECT().
GetOldClientLicense(context.TODO(), "").
Return(mockLicense, &model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := getLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
// Verify human-readable dates are formatted correctly
output := printer.GetLines()[0].(map[string]string)
s.Equal("2021-01-01T00:00:00Z", output["IssuedAtReadable"])
s.Equal("2021-01-01T00:00:00Z", output["StartsAtReadable"])
s.Equal("2022-01-01T00:00:00Z", output["ExpiresAtReadable"])
s.Equal("Test Company", output["Company"])
s.Equal("enterprise", output["SkuShortName"])
})
s.Run("No license installed", func() {
printer.Clean()
mockLicense := map[string]string{
"IsLicensed": "false",
}
s.client.
EXPECT().
GetOldClientLicense(context.TODO(), "").
Return(mockLicense, &model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := getLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "No license installed")
})
s.Run("Fail to get license", func() {
printer.Clean()
mockErr := errors.New("mock error")
s.client.
EXPECT().
GetOldClientLicense(context.TODO(), "").
Return(nil, &model.Response{StatusCode: http.StatusInternalServerError}, mockErr).
Times(1)
err := getLicenseCmdF(s.client, &cobra.Command{}, []string{})
s.Require().NotNil(err)
s.Require().Equal(err, mockErr)
s.Require().Len(printer.GetLines(), 0)
})
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer/human"
)
var LogsCmd = &cobra.Command{
Use: "logs",
Short: "Display logs in a human-readable format",
Long: "Display logs in a human-readable format. As the logs format depends on the server, the \"--format\" flag cannot be used with this command.",
RunE: withClient(logsCmdF),
}
func init() {
LogsCmd.Flags().IntP("number", "n", DefaultPageSize, "Number of log lines to retrieve.")
LogsCmd.Flags().BoolP("logrus", "l", false, "Use logrus for formatting.")
RootCmd.AddCommand(LogsCmd)
}
func logsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("format") || cmd.Flags().Changed("json") {
return fmt.Errorf("the %q and %q flags cannot be used with this command", "--format", "--json")
} else if viper.GetString("format") == printer.FormatJSON {
return fmt.Errorf("json formatting cannot be applied on this command. Please check the value of %q", "MMCTL_FORMAT")
}
number, _ := cmd.Flags().GetInt("number")
logLines, _, err := c.GetLogs(context.TODO(), 0, number)
if err != nil {
return errors.New("Unable to retrieve logs. Error: " + err.Error())
}
reader := bytes.NewReader([]byte(strings.Join(logLines, "")))
var writer human.LogWriter
if logrus, _ := cmd.Flags().GetBool("logrus"); logrus {
writer = human.NewLogrusWriter(os.Stdout)
} else {
writer = human.NewSimpleWriter(os.Stdout)
}
human.ProcessLogs(reader, writer)
return nil
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestlogsCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("Display single log line", func(c client.Client) {
cmd := &cobra.Command{}
cmd.Flags().Int("number", 1, "")
data, err := testLogsCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(data, 2)
})
s.RunForSystemAdminAndLocal("Display in logrus for formatting", func(c client.Client) {
cmd := &cobra.Command{}
cmd.Flags().Bool("logrus", true, "")
cmd.Flags().Int("number", 1, "")
data, err := testLogsCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(data, 2)
s.Contains(data[1], "time=")
s.Contains(data[1], "level=")
s.Contains(data[1], "msg=")
})
s.Run("Should not allow normal user to retrieve logs", func() {
cmd := &cobra.Command{}
cmd.Flags().Int("number", 1, "")
_, err := testLogsCmdF(s.th.Client, cmd, []string{})
s.Require().Error(err)
})
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
)
const (
testLogInfo = `{"level":"info","ts":1573516747,"caller":"app/server.go:490","msg":"Server is listening on [::]:8065"}`
testLogInfoStdout = "info app/server.go:490 Server is listening on [::]:8065"
testLogrusStdout = "level=info msg=\"Server is listening on [::]:8065\" caller=\"app/server.go:490\""
)
func (s *MmctlUnitTestSuite) TestLogsCmd() {
s.Run("Display single log line", func() {
mockSingleLogLine := []string{testLogInfo}
cmd := &cobra.Command{}
cmd.Flags().Int("number", 1, "")
s.client.
EXPECT().
GetLogs(context.TODO(), 0, 1).
Return(mockSingleLogLine, &model.Response{}, nil).
Times(1)
data, err := testLogsCmdF(s.client, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(data, 1)
s.Contains(data[0], testLogInfoStdout)
})
s.Run("Display logs", func() {
mockSingleLogLine := []string{testLogInfo}
cmd := &cobra.Command{}
s.client.
EXPECT().
GetLogs(context.TODO(), 0, 0).
Return(mockSingleLogLine, &model.Response{}, nil).
Times(1)
data, err := testLogsCmdF(s.client, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(data, 1)
s.Contains(data[0], testLogInfoStdout)
})
s.Run("Display logs logrus format", func() {
mockSingleLogLine := []string{testLogInfo}
cmd := &cobra.Command{}
cmd.Flags().Bool("logrus", true, "")
cmd.Flags().Int("number", 1, "")
s.client.
EXPECT().
GetLogs(context.TODO(), 0, 1).
Return(mockSingleLogLine, &model.Response{}, nil).
Times(1)
data, err := testLogsCmdF(s.client, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(data, 1)
s.Contains(data[0], testLogrusStdout)
})
s.Run("Error when using format flag", func() {
cmd := &cobra.Command{}
cmd.Flags().String("format", "json", "")
cmd.Flags().Lookup("format").Changed = true
data, err := testLogsCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Equal(err.Error(), fmt.Sprintf("the %q and %q flags cannot be used with this command", "--format", "--json"))
s.Require().Len(data, 0)
cmd.Flags().Lookup("format").Changed = false
cmd.Flags().Bool("json", true, "")
cmd.Flags().Lookup("json").Changed = true
data, err = testLogsCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Equal(err.Error(), fmt.Sprintf("the %q and %q flags cannot be used with this command", "--format", "--json"))
s.Require().Len(data, 0)
})
s.Run("Error when setting json format with environment variable", func() {
formatTmp := viper.GetString("format")
cmd := &cobra.Command{}
viper.Set("format", "json")
data, err := testLogsCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Equal(err.Error(), "json formatting cannot be applied on this command. Please check the value of \"MMCTL_FORMAT\"")
s.Require().Len(data, 0)
viper.Set("format", formatTmp)
})
}
// testLogsCmdF is a wrapper around the logsCmdF function to capture
// stdout for testing
func testLogsCmdF(client client.Client, cmd *cobra.Command, args []string) ([]string, error) {
// Redirect stdout
currStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Call logsCmdF
err := logsCmdF(client, cmd, args)
if err != nil {
return nil, err
}
// Stop capturing, set stdout back
w.Close()
os.Stdout = currStdout
// Copy to buffer
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
if err != nil {
return nil, err
}
// Split for individual lines, removing last as it is an empty string
data := strings.Split(buf.String(), "\n")
data = data[:len(data)-1]
return data, err
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build e2e
package commands
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
func TestMain(m *testing.M) {
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper := testlib.NewMainHelperWithOptions(&options)
api4.SetMainHelper(mainHelper)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build e2e
package commands
import (
"testing"
"github.com/stretchr/testify/suite"
)
func TestMmctlE2ESuite(t *testing.T) {
suite.Run(t, new(MmctlE2ETestSuite))
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"time"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/mocks"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
var EnableEnterpriseTests string
type MmctlUnitTestSuite struct {
suite.Suite
mockCtrl *gomock.Controller
client *mocks.MockClient
}
func (s *MmctlUnitTestSuite) SetupTest() {
viper.Reset()
printer.Clean()
printer.SetFormat(printer.FormatJSON)
s.mockCtrl = gomock.NewController(s.T())
s.client = mocks.NewMockClient(s.mockCtrl)
}
func (s *MmctlUnitTestSuite) TearDownTest() {
s.mockCtrl.Finish()
viper.Reset()
}
type MmctlE2ETestSuite struct {
suite.Suite
th *api4.TestHelper
}
func (s *MmctlE2ETestSuite) SetupTest() {
printer.Clean()
printer.SetFormat(printer.FormatJSON)
}
func (s *MmctlE2ETestSuite) TearDownTest() {
// Remove the test helper from the structure to avoid reusing the same helper between tests
s.th = nil
}
func (s *MmctlE2ETestSuite) SetupTestHelper() *api4.TestHelper {
s.th = api4.Setup(s.T())
return s.th
}
func (s *MmctlE2ETestSuite) SetupTestHelperWithConfig(updateConfig func(cfg *model.Config)) *api4.TestHelper {
s.th = api4.SetupConfig(s.T(), updateConfig)
return s.th
}
func (s *MmctlE2ETestSuite) SetupEnterpriseTestHelper() *api4.TestHelper {
if EnableEnterpriseTests != "true" {
s.T().SkipNow()
}
s.th = api4.SetupEnterprise(s.T())
return s.th
}
func (s *MmctlE2ETestSuite) SetupMessageExportTestHelper() *api4.TestHelper {
if EnableEnterpriseTests != "true" {
s.T().SkipNow()
}
jobs.DefaultWatcherPollingInterval = 100
s.th = api4.SetupEnterprise(s.T()).InitBasic(s.T())
s.th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.DownloadExportResults = true
*cfg.MessageExportSettings.EnableExport = true
*cfg.MessageExportSettings.ExportFormat = model.ComplianceExportTypeActiance
})
err := s.th.App.Srv().Jobs.StartWorkers()
require.NoError(s.T(), err)
err = s.th.App.Srv().Jobs.StartSchedulers()
require.NoError(s.T(), err)
return s.th
}
// RunForSystemAdminAndLocal runs a test function for both SystemAdmin
// and Local clients. Several commands work in the same way when used
// by a fully privileged user and through the local mode, so this
// helper facilitates checking both
func (s *MmctlE2ETestSuite) RunForSystemAdminAndLocal(testName string, fn func(client.Client)) {
s.Run(testName+"/SystemAdminClient", func() {
fn(s.th.SystemAdminClient)
})
s.Run(testName+"/LocalClient", func() {
fn(s.th.LocalClient)
})
}
// RunForAllClients runs a test function for all the clients
// registered in the TestHelper
func (s *MmctlE2ETestSuite) RunForAllClients(testName string, fn func(client.Client)) {
s.Run(testName+"/Client", func() {
fn(s.th.Client)
})
s.Run(testName+"/SystemAdminClient", func() {
fn(s.th.SystemAdminClient)
})
s.Run(testName+"/LocalClient", func() {
fn(s.th.LocalClient)
})
}
func (s *MmctlE2ETestSuite) CheckErrorID(err error, errorId string) {
api4.CheckErrorID(s.T(), err, errorId)
}
// Helper functions for compliance export job testing
// getMostRecentJobWithId gets the most recent job with the specified ID
func (s *MmctlE2ETestSuite) getMostRecentJobWithId(id string) *model.Job {
list, _, err := s.th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeMessageExport, 0, 1)
s.Require().NoError(err)
s.Require().Len(list, 1)
s.Require().Equal(id, list[0].Id)
return list[0]
}
// checkJobForStatus polls until the job with the specified ID reaches the expected status
func (s *MmctlE2ETestSuite) checkJobForStatus(id string, status string) {
doneChan := make(chan bool)
var job *model.Job
go func() {
defer close(doneChan)
for {
job = s.getMostRecentJobWithId(id)
if job.Status == status {
break
}
time.Sleep(100 * time.Millisecond)
}
s.Require().Equal(status, job.Status)
}()
select {
case <-doneChan:
case <-time.After(15 * time.Second):
s.Require().Fail(fmt.Sprintf("expected job's status to be %s, got %s", status, job.Status))
}
}
// runJobForTest creates a job and waits for it to complete
func (s *MmctlE2ETestSuite) runJobForTest(jobData map[string]string) *model.Job {
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(),
&model.Job{Type: model.JobTypeMessageExport, Data: jobData})
s.Require().NoError(err)
// poll until completion
s.checkJobForStatus(job.Id, model.JobStatusSuccess)
job = s.getMostRecentJobWithId(job.Id)
return job
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build unit
package commands
import (
"testing"
"github.com/stretchr/testify/suite"
)
func TestMmctlUnitSuite(t *testing.T) {
suite.Run(t, new(MmctlUnitTestSuite))
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var OAuthCmd = &cobra.Command{
Use: "oauth",
Short: "Management of OAuth2 apps",
}
var ListOAuthAppsCmd = &cobra.Command{
Use: "list",
Short: "List OAuth2 apps",
Long: "list all OAuth2 apps",
Example: " oauth list",
RunE: withClient(listOAuthAppsCmdF),
Args: cobra.NoArgs,
}
func listOAuthAppsCmdF(c client.Client, command *cobra.Command, args []string) error {
page, err := command.Flags().GetInt("page")
if err != nil {
return err
}
perPage, err := command.Flags().GetInt("per-page")
if err != nil {
return err
}
apps, _, err := c.GetOAuthApps(context.Background(), page, perPage)
if err != nil {
return errors.Wrap(err, "Failed to fetch oauth2 apps")
}
userIds := make([]string, len(apps))
for i := range apps {
userIds[i] = apps[i].CreatorId
}
users, _, err := c.GetUsersByIds(context.Background(), userIds)
if err != nil {
return errors.Wrap(err, "Failed to fetch users for oauth2 apps")
}
usersByID := map[string]*model.User{}
for _, user := range users {
usersByID[user.Id] = user
}
for _, app := range apps {
ownerName := app.CreatorId
if owner, ok := usersByID[app.CreatorId]; ok {
ownerName = owner.Username
}
printer.PrintT(fmt.Sprintf("{{.Id}}: {{.Name}} (Created by %s)", ownerName), app)
}
return nil
}
func init() {
ListOAuthAppsCmd.Flags().Int("page", 0, "Page number to fetch for the list of OAuth2 apps")
ListOAuthAppsCmd.Flags().Int("per-page", DefaultPageSize, "Number of OAuth2 apps to be fetched")
OAuthCmd.AddCommand(
ListOAuthAppsCmd,
)
RootCmd.AddCommand(OAuthCmd)
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestListOAuthAppsCmd() {
oauthAppID := "oauthAppID"
oauthAppName := "oauthAppName"
userID := "userID"
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 200, "")
s.Run("Listing oauth apps", func() {
printer.Clean()
mockOAuthApp := model.OAuthApp{
Id: oauthAppID,
Name: oauthAppName,
CreatorId: userID,
}
mockUser := model.User{Id: mockOAuthApp.CreatorId, Username: "mockuser"}
s.client.
EXPECT().
GetOAuthApps(context.Background(), 0, 200).
Return([]*model.OAuthApp{&mockOAuthApp}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.Background(), []string{mockOAuthApp.CreatorId}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := listOAuthAppsCmdF(s.client, cmd, []string{})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Require().Equal(&mockOAuthApp, printer.GetLines()[0])
})
s.Run("Listing oauth apps with paging", func() {
printer.Clean()
mockOAuthApp := model.OAuthApp{
Id: oauthAppID,
Name: oauthAppName,
CreatorId: userID,
}
mockUser := model.User{Id: mockOAuthApp.CreatorId, Username: "mockuser"}
pageCmd := &cobra.Command{}
pageCmd.Flags().Int("page", 0, "")
pageCmd.Flags().Int("per-page", 200, "")
page := 1
perPage := 2
_ = pageCmd.Flags().Set("page", strconv.Itoa(page))
_ = pageCmd.Flags().Set("per-page", strconv.Itoa(perPage))
s.client.
EXPECT().
GetOAuthApps(context.Background(), 1, 2).
Return([]*model.OAuthApp{&mockOAuthApp}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.Background(), []string{mockOAuthApp.CreatorId}).
Return([]*model.User{&mockUser}, &model.Response{}, nil).
Times(1)
err := listOAuthAppsCmdF(s.client, pageCmd, []string{})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Require().Equal(&mockOAuthApp, printer.GetLines()[0])
})
s.Run("Unable to list oauth apps", func() {
printer.Clean()
mockError := errors.New("mock error")
s.client.
EXPECT().
GetOAuthApps(context.Background(), 0, 200).
Return(nil, &model.Response{}, mockError).
Times(1)
err := listOAuthAppsCmdF(s.client, cmd, []string{})
s.Require().NotNil(err)
s.Len(printer.GetLines(), 0)
s.EqualError(err, "Failed to fetch oauth2 apps: mock error")
})
s.Run("Unable to get users for oauth apps", func() {
printer.Clean()
mockOAuthApp := model.OAuthApp{
Id: oauthAppID,
Name: oauthAppName,
CreatorId: userID,
}
mockError := errors.New("mock error")
s.client.
EXPECT().
GetOAuthApps(context.Background(), 0, 200).
Return([]*model.OAuthApp{&mockOAuthApp}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUsersByIds(context.Background(), []string{mockOAuthApp.CreatorId}).
Return(nil, &model.Response{}, mockError).
Times(1)
err := listOAuthAppsCmdF(s.client, cmd, []string{})
s.Require().NotNil(err)
s.Len(printer.GetLines(), 0)
s.EqualError(err, "Failed to fetch users for oauth2 apps: mock error")
})
}

View File

@@ -0,0 +1,408 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestAssignUsersCmd() {
s.Run("Assigning a user to a role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
mockUser := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: "system_user",
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), mockUser.Username, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, fmt.Sprintf("%s %s", mockUser.Roles, mockRole.Name)).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
args := []string{mockRole.Name, mockUser.Username}
err := assignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Assigning multiple users to a role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
mockUser1 := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: "system_user",
}
mockUser2 := &model.User{
Id: model.NewId(),
Username: "user2",
Roles: "system_user system_admin",
}
notFoundUser := &model.User{
Username: "notfound",
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
for _, user := range []*model.User{mockUser1, mockUser2} {
s.client.
EXPECT().
GetUserByUsername(context.TODO(), user.Username, "").
Return(user, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), user.Id, fmt.Sprintf("%s %s", user.Roles, mockRole.Name)).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), notFoundUser.Username, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), notFoundUser.Username, "").
Return(nil, &model.Response{}, nil).
Times(1)
expectedError := &multierror.Error{}
expectedError = multierror.Append(expectedError, fmt.Errorf("couldn't find user 'notfound'"))
args := []string{mockRole.Name, mockUser1.Username, notFoundUser.Username, mockUser2.Username}
err := assignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Equal(expectedError.ErrorOrNil(), err)
})
s.Run("Assigning to a non-existent role", func() {
expectedError := errors.New("role_not_found")
s.client.
EXPECT().
GetRoleByName(context.TODO(), "non-existent").
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, expectedError).
Times(1)
args := []string{"non-existent", "user1"}
err := assignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Equal(expectedError, err)
})
s.Run("Assigning a user to a role that is already assigned", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
mockUser := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: "system_user mock-role",
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), mockUser.Username, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, mockUser.Username}
err := assignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Assigning a user that is not found", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
requestedUser := "user99"
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), requestedUser, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), requestedUser, "").
Return(nil, &model.Response{}, nil).
Times(1)
expectedError := &multierror.Error{}
expectedError = multierror.Append(expectedError, fmt.Errorf("couldn't find user '%s'", requestedUser))
args := []string{mockRole.Name, requestedUser}
err := assignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().NotNil(err)
s.Require().Equal(expectedError.ErrorOrNil(), err)
})
}
func (s *MmctlUnitTestSuite) TestUnassignUsersCmd() {
s.Run("Unassigning a user from a role", func() {
roleName := "mock-role"
mockUser := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: fmt.Sprintf("system_user %s team_admin", roleName),
}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), mockUser.Username, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, "system_user team_admin").
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
args := []string{roleName, mockUser.Username}
err := unassignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Unassign multiple users from a role", func() {
roleName := "mock-role"
mockUser1 := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: "system_user mock-role",
}
mockUser2 := &model.User{
Id: model.NewId(),
Username: "user2",
Roles: "system_user system_admin mock-role",
}
notFoundUser := &model.User{
Username: "notfound",
}
for _, user := range []*model.User{mockUser1, mockUser2} {
s.client.
EXPECT().
GetUserByUsername(context.TODO(), user.Username, "").
Return(user, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), user.Id, strings.TrimSpace(strings.ReplaceAll(user.Roles, roleName, ""))).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), notFoundUser.Username, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), notFoundUser.Username, "").
Return(nil, &model.Response{}, nil).
Times(1)
args := []string{roleName, mockUser1.Username, notFoundUser.Username, mockUser2.Username}
err := unassignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Unassign from a non-assigned or role", func() {
roleName := "mock-role"
mockUser := &model.User{
Id: model.NewId(),
Username: "user1",
Roles: "system_user",
}
s.client.
EXPECT().
GetUserByUsername(context.TODO(), mockUser.Username, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
args := []string{roleName, mockUser.Username}
err := unassignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Unassigning a user that is not found", func() {
requestedUser := "user99"
s.client.
EXPECT().
GetUserByUsername(context.TODO(), requestedUser, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), requestedUser, "").
Return(nil, &model.Response{}, nil).
Times(1)
args := []string{"mock-role-id", requestedUser}
err := unassignUsersCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
}
func (s *MmctlUnitTestSuite) TestShowRoleCmd() {
s.Run("Show custom role", func() {
printer.Clean()
printer.SetFormat(printer.FormatPlain)
defer printer.SetFormat(printer.FormatJSON)
commandArg := "example-role-name"
mockRole := &model.Role{
Id: "example-mock-id",
Name: commandArg,
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
err := showRoleCmdF(s.client, &cobra.Command{}, []string{commandArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Equal(`
Property Value
-------- -----
Name example-role-name
DisplayName
BuiltIn false
SchemeManaged false
`, printer.GetLines()[0])
})
s.Run("Show a role with a sysconsole_* permission", func() {
printer.Clean()
printer.SetFormat(printer.FormatPlain)
defer printer.SetFormat(printer.FormatJSON)
commandArg := "example-role-name"
mockRole := &model.Role{
Id: "example-mock-id",
Name: commandArg,
Permissions: []string{"sysconsole_write_site", "edit_brand"},
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
err := showRoleCmdF(s.client, &cobra.Command{}, []string{commandArg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Equal(`
Property Value Used by
-------- ----- -------
Name example-role-name
DisplayName
BuiltIn false
SchemeManaged false
Permissions edit_brand
sysconsole_write_site
`, printer.GetLines()[0])
})
s.Run("Show custom role with invalid name", func() {
printer.Clean()
expectedError := errors.New("role_not_found")
commandArgBogus := "bogus-role-name"
// showRoleCmdF will look up role by name
s.client.
EXPECT().
GetRoleByName(context.TODO(), commandArgBogus).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, expectedError).
Times(1)
err := showRoleCmdF(s.client, &cobra.Command{}, []string{commandArgBogus})
s.Require().NotNil(err)
s.Require().Equal(expectedError, err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var PermissionsCmd = &cobra.Command{
Use: "permissions",
Short: "Management of permissions",
}
var AddPermissionsCmd = &cobra.Command{
Use: "add <role> <permission...>",
Short: "Add permissions to a role (EE Only)",
Long: `Add one or more permissions to an existing role (Only works in Enterprise Edition).`,
Example: ` permissions add system_user list_open_teams
permissions add system_manager sysconsole_read_user_management_channels`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(addPermissionsCmdF),
}
var RemovePermissionsCmd = &cobra.Command{
Use: "remove <role> <permission...>",
Short: "Remove permissions from a role (EE Only)",
Long: `Remove one or more permissions from an existing role (Only works in Enterprise Edition).`,
Example: ` permissions remove system_user list_open_teams
permissions remove system_manager sysconsole_read_user_management_channels`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(removePermissionsCmdF),
}
var ResetCmd = &cobra.Command{
Use: "reset <role_name>",
Short: "Reset default permissions for role (EE Only)",
Long: "Reset the given role's permissions to the set that was originally released with",
Example: ` # Reset the permissions of the 'system_read_only_admin' role.
$ mmctl permissions reset system_read_only_admin`,
Args: cobra.ExactArgs(1),
RunE: withClient(resetPermissionsCmdF),
}
func init() {
PermissionsCmd.AddCommand(
AddPermissionsCmd,
RemovePermissionsCmd,
ResetCmd,
)
RootCmd.AddCommand(PermissionsCmd)
}
func addPermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
role, _, err := c.GetRoleByName(context.TODO(), args[0])
if err != nil {
return err
}
newPermissions := role.Permissions
for _, permissionID := range args[1:] {
newPermissions = append(newPermissions, permissionID)
if ancillaryPermissions, ok := model.SysconsoleAncillaryPermissions[permissionID]; ok {
for _, ancillaryPermission := range ancillaryPermissions {
newPermissions = append(newPermissions, ancillaryPermission.Id)
}
}
}
patchRole := model.RolePatch{
Permissions: &newPermissions,
}
if _, _, err = c.PatchRole(context.TODO(), role.Id, &patchRole); err != nil {
return err
}
return nil
}
func removePermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
role, _, err := c.GetRoleByName(context.TODO(), args[0])
if err != nil {
return err
}
newPermissionSet := role.Permissions
for _, permissionID := range args[1:] {
newPermissionSet = removeFromStringSlice(newPermissionSet, permissionID)
}
var ancillaryPermissionsStillUsed []*model.Permission
for _, permissionID := range newPermissionSet {
if ancillaryPermissions, ok := model.SysconsoleAncillaryPermissions[permissionID]; ok {
ancillaryPermissionsStillUsed = append(ancillaryPermissionsStillUsed, ancillaryPermissions...)
}
}
for _, permissionID := range args[1:] {
if ancillaryPermissions, ok := model.SysconsoleAncillaryPermissions[permissionID]; ok {
for _, permission := range ancillaryPermissions {
if !permissionsSliceIncludes(ancillaryPermissionsStillUsed, permission) {
newPermissionSet = removeFromStringSlice(newPermissionSet, permission.Id)
}
}
}
}
patchRole := model.RolePatch{
Permissions: &newPermissionSet,
}
if _, _, err = c.PatchRole(context.TODO(), role.Id, &patchRole); err != nil {
return err
}
return nil
}
func resetPermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
role, _, err := c.GetRoleByName(context.TODO(), args[0])
if err != nil {
return err
}
defaultRole, ok := model.MakeDefaultRoles()[role.Name]
if !ok {
return fmt.Errorf("no default permissions available for role")
}
patchRole := model.RolePatch{
Permissions: &defaultRole.Permissions,
}
role, _, err = c.PatchRole(context.TODO(), role.Id, &patchRole)
if err != nil {
return err
}
printer.PrintT(prettyRole(role), nil)
return nil
}
func removeFromStringSlice(items []string, item string) []string {
newPermissions := []string{}
for _, x := range items {
if x != item {
newPermissions = append(newPermissions, x)
}
}
return newPermissions
}
func permissionsSliceIncludes(haystack []*model.Permission, needle *model.Permission) bool {
for _, item := range haystack {
if item.Id == needle.Id {
return true
}
}
return false
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestShowRoleCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.RunForAllClients("MM-T3928 Should allow all users to see a role", func(c client.Client) {
printer.Clean()
err := showRoleCmdF(c, &cobra.Command{}, []string{model.SystemAdminRoleId})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForAllClients("MM-T3959 Should return error to all users for a none exitent role", func(c client.Client) {
printer.Clean()
err := showRoleCmdF(c, &cobra.Command{}, []string{"none_existent_role"})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestAddPermissionsCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
role, appErr := s.th.App.GetRoleByName(s.th.Context, model.SystemUserRoleId)
s.Require().Nil(appErr)
s.Require().NotContains(role.Permissions, model.PermissionCreateBot.Id)
s.Run("MM-T3961 Should not allow normal user to add a permission to a role", func() {
printer.Clean()
err := addPermissionsCmdF(s.th.Client, &cobra.Command{}, []string{model.SystemUserRoleId, model.PermissionCreateBot.Id})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3960 Should be able to add a permission to a role", func(c client.Client) {
printer.Clean()
err := addPermissionsCmdF(c, &cobra.Command{}, []string{model.SystemUserRoleId, model.PermissionCreateBot.Id})
s.Require().NoError(err)
defer func() {
permissions := role.Permissions
newRole, appErr := s.th.App.PatchRole(role, &model.RolePatch{Permissions: &permissions})
s.Require().Nil(appErr)
s.Require().NotContains(newRole.Permissions, model.PermissionCreateBot.Id)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
updatedRole, appErr := s.th.App.GetRoleByName(s.th.Context, model.SystemUserRoleId)
s.Require().Nil(appErr)
s.Require().Contains(updatedRole.Permissions, model.PermissionCreateBot.Id)
})
}
func (s *MmctlE2ETestSuite) TestRemovePermissionsCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
role, appErr := s.th.App.GetRoleByName(s.th.Context, model.SystemUserRoleId)
s.Require().Nil(appErr)
s.Require().Contains(role.Permissions, model.PermissionCreateDirectChannel.Id)
s.Run("MM-T3963 Should not allow normal user to remove a permission from a role", func() {
printer.Clean()
err := removePermissionsCmdF(s.th.Client, &cobra.Command{}, []string{model.SystemUserRoleId, model.PermissionCreateDirectChannel.Id})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3962 Should be able to remove a permission from a role", func(c client.Client) {
printer.Clean()
err := removePermissionsCmdF(c, &cobra.Command{}, []string{model.SystemUserRoleId, model.PermissionCreateDirectChannel.Id})
s.Require().NoError(err)
defer func() {
permissions := []string{model.PermissionCreateDirectChannel.Id}
newRole, appErr := s.th.App.PatchRole(role, &model.RolePatch{Permissions: &permissions})
s.Require().Nil(appErr)
s.Require().Contains(newRole.Permissions, model.PermissionCreateDirectChannel.Id)
}()
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
updatedRole, appErr := s.th.App.GetRoleByName(s.th.Context, model.SystemUserRoleId)
s.Require().Nil(appErr)
s.Require().NotContains(updatedRole.Permissions, model.PermissionCreateDirectChannel.Id)
})
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
)
func (s *MmctlE2ETestSuite) TestResetPermissionsCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.Run("Shouldn't let a non-system-admin reset a role's permissions", func() {
printer.Clean()
// update the role to have some non-default permissions
role, err := s.th.App.GetRoleByName(s.th.Context, model.SystemUserManagerRoleId)
s.Require().Nil(err)
defaultPermissions := role.Permissions
expectedPermissions := []string{model.PermissionUseGroupMentions.Id, model.PermissionUseChannelMentions.Id}
role.Permissions = expectedPermissions
role, err = s.th.App.UpdateRole(role)
s.Require().Nil(err)
// reset to defaults when we're done
defer func() {
role.Permissions = defaultPermissions
_, err = s.th.App.UpdateRole(role)
s.Require().Nil(err)
}()
// try to reset the permissions
err2 := resetPermissionsCmdF(s.th.Client, &cobra.Command{}, []string{model.SystemUserManagerRoleId})
s.Require().Error(err2)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
// ensure reset didn't happen
roleAfterResetAttempt, err := s.th.App.GetRoleByName(s.th.Context, model.SystemUserManagerRoleId)
s.Require().Nil(err)
s.Require().ElementsMatch(expectedPermissions, roleAfterResetAttempt.Permissions)
})
s.RunForSystemAdminAndLocal("Reset a role's permissions", func(c client.Client) {
printer.Clean()
// update the role to have some non-default permissions
role, err := s.th.App.GetRoleByName(s.th.Context, model.SystemUserManagerRoleId)
s.Require().Nil(err)
defaultPermissions := role.Permissions
expectedPermissions := []string{model.PermissionUseGroupMentions.Id, model.PermissionUseChannelMentions.Id}
role.Permissions = expectedPermissions
role, err = s.th.App.UpdateRole(role)
// reset to defaults when we're done
defer func() {
role.Permissions = defaultPermissions
_, err = s.th.App.UpdateRole(role)
s.Require().Nil(err)
}()
// try to reset the permissions
err2 := resetPermissionsCmdF(c, &cobra.Command{}, []string{model.SystemUserManagerRoleId})
s.Require().Nil(err2)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
// ensure reset was successful
roleAfterResetAttempt, err := s.th.App.GetRoleByName(s.th.Context, model.SystemUserManagerRoleId)
s.Require().Nil(err)
s.Require().ElementsMatch(defaultPermissions, roleAfterResetAttempt.Permissions)
})
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"slices"
"strings"
"text/tabwriter"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
)
var RoleCmd = &cobra.Command{
Use: "role",
Short: "Management of roles",
}
var ShowCmd = &cobra.Command{
Use: "show <role_name>",
Short: "Show the role information",
Long: "Show all the information about a role.",
Example: ` permissions role show system_user`,
Args: cobra.ExactArgs(1),
RunE: withClient(showRoleCmdF),
}
var AssignCmd = &cobra.Command{
Use: "assign <role_name> <username...>",
Short: "Assign users to role (EE Only)",
Long: "Assign users to a role by username (Only works in Enterprise Edition).",
Example: ` # Assign users with usernames 'john.doe' and 'jane.doe' to the role named 'system_admin'.
permissions assign system_admin john.doe jane.doe
# Examples using other system roles
permissions assign system_manager john.doe jane.doe
permissions assign system_user_manager john.doe jane.doe
permissions assign system_read_only_admin john.doe jane.doe`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(assignUsersCmdF),
}
var UnassignCmd = &cobra.Command{
Use: "unassign <role_name> <username...>",
Short: "Unassign users from role (EE Only)",
Long: "Unassign users from a role by username (Only works in Enterprise Edition).",
Example: ` # Unassign users with usernames 'john.doe' and 'jane.doe' from the role named 'system_admin'.
permissions unassign system_admin john.doe jane.doe
# Examples using other system roles
permissions unassign system_manager john.doe jane.doe
permissions unassign system_user_manager john.doe jane.doe
permissions unassign system_read_only_admin john.doe jane.doe`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(unassignUsersCmdF),
}
func init() {
RoleCmd.AddCommand(
AssignCmd,
UnassignCmd,
ShowCmd,
)
PermissionsCmd.AddCommand(
RoleCmd,
)
}
func prettyRole(role *model.Role) string {
slices.Sort(role.Permissions)
consolePermissionMap := map[string]bool{}
for _, perm := range role.Permissions {
if strings.HasPrefix(perm, "sysconsole_") {
consolePermissionMap[perm] = true
}
}
getUsedBy := func(permissionID string) []string {
var usedByIDs []string
if !strings.HasPrefix(permissionID, "sysconsole_") {
usedBy := map[string]bool{} // map to make a unique set
for key, vals := range model.SysconsoleAncillaryPermissions {
for _, val := range vals {
if val.Id == permissionID {
if _, ok := consolePermissionMap[key]; ok {
usedBy[key] = true
}
}
}
}
for key := range usedBy {
usedByIDs = append(usedByIDs, key)
}
}
return usedByIDs
}
var b strings.Builder
w := tabwriter.NewWriter(&b, 0, 0, 1, ' ', 0)
// Only show the 3-column view if the role has sysconsole permissions
// sysadmin has every permission, so no point in showing the "Used by"
// column.
if len(consolePermissionMap) > 0 && role.Name != "system_admin" {
fmt.Fprintf(w, "\nProperty\tValue\tUsed by\n")
fmt.Fprintf(w, "--------\t-----\t-------\n")
fmt.Fprintf(w, "Name\t%s\t\n", role.Name)
fmt.Fprintf(w, "DisplayName\t%s\t\n", role.DisplayName)
fmt.Fprintf(w, "BuiltIn\t%v\t\n", role.BuiltIn)
fmt.Fprintf(w, "SchemeManaged\t%v\t\n", role.SchemeManaged)
for i, perm := range role.Permissions {
if i == 0 {
fmt.Fprintf(w, "Permissions\t%s\t%v\n", role.Permissions[0], strings.Join(getUsedBy(role.Permissions[0]), ", "))
} else {
fmt.Fprintf(w, "\t%s\t%v\n", perm, strings.Join(getUsedBy(perm), ", "))
}
}
} else {
fmt.Fprintf(w, "\nProperty\tValue\n")
fmt.Fprintf(w, "--------\t-----\n")
fmt.Fprintf(w, "Name\t%s\n", role.Name)
fmt.Fprintf(w, "DisplayName\t%s\n", role.DisplayName)
fmt.Fprintf(w, "BuiltIn\t%v\n", role.BuiltIn)
fmt.Fprintf(w, "SchemeManaged\t%v\n", role.SchemeManaged)
for i, perm := range role.Permissions {
if i == 0 {
fmt.Fprintf(w, "Permissions\t%s\n", role.Permissions[0])
} else {
fmt.Fprintf(w, "\t%s\n", perm)
}
}
}
w.Flush()
return b.String()
}
func showRoleCmdF(c client.Client, cmd *cobra.Command, args []string) error {
role, _, err := c.GetRoleByName(context.TODO(), args[0])
if err != nil {
return err
}
printer.PrintT(prettyRole(role), nil)
return nil
}
func assignUsersCmdF(c client.Client, cmd *cobra.Command, args []string) error {
role, _, err := c.GetRoleByName(context.TODO(), args[0])
if err != nil {
return err
}
users := getUsersFromUserArgs(c, args[1:])
var errs *multierror.Error
for i, user := range users {
if user == nil {
printer.PrintError("Couldn't find user '" + args[i+1] + "'.")
errs = multierror.Append(errs, fmt.Errorf("couldn't find user '%s'", args[i+1]))
continue
}
var userHasRequestedRole bool
startingRoles := strings.Fields(user.Roles)
for _, roleName := range startingRoles {
if roleName == role.Name {
userHasRequestedRole = true
}
}
if userHasRequestedRole {
continue
}
userRoles := startingRoles
userRoles = append(userRoles, role.Name)
_, err = c.UpdateUserRoles(context.TODO(), user.Id, strings.Join(userRoles, " "))
if err != nil {
return err
}
}
return errs.ErrorOrNil()
}
func unassignUsersCmdF(c client.Client, cmd *cobra.Command, args []string) error {
users := getUsersFromUserArgs(c, args[1:])
for i, user := range users {
if user == nil {
printer.PrintError("Couldn't find user '" + args[i+1] + "'.")
continue
}
userRoles := strings.Fields(user.Roles)
originalCount := len(userRoles)
for j := 0; j < len(userRoles); j++ {
if userRoles[j] == args[0] {
userRoles = append(userRoles[:j], userRoles[j+1:]...)
j--
}
}
if originalCount > len(userRoles) {
_, err := c.UpdateUserRoles(context.TODO(), user.Id, strings.Join(userRoles, " "))
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestAssignUsersCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
s.Run("MM-T3721 Should not allow normal user to assign a role", func() {
printer.Clean()
err := assignUsersCmdF(s.th.Client, &cobra.Command{}, []string{model.SystemAdminRoleId, user.Email})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3722 Assigning a user to a non-existent role", func(c client.Client) {
printer.Clean()
err := assignUsersCmdF(c, &cobra.Command{}, []string{"not_a_role", user.Email})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Assigning non existen user to a role", func(c client.Client) {
printer.Clean()
err := assignUsersCmdF(c, &cobra.Command{}, []string{model.SystemManagerRoleId, "non_existent_user"})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], "Couldn't find user 'non_existent_user'.")
})
s.RunForSystemAdminAndLocal("MM-T3648 Assigning a user to a role", func(c client.Client) {
printer.Clean()
err := assignUsersCmdF(c, &cobra.Command{}, []string{model.SystemManagerRoleId, user.Email})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
roles := user.Roles
u, err2 := s.th.App.GetUser(s.th.Context, user.Id)
s.Require().Nil(err2)
s.Require().True(u.IsInRole(model.SystemManagerRoleId))
_, err2 = s.th.App.UpdateUserRoles(s.th.Context, user.Id, roles, false)
s.Require().Nil(err2)
})
}
func (s *MmctlE2ETestSuite) TestUnassignUsersCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewUsername(), Password: model.NewId()})
s.Require().Nil(appErr)
s.Run("MM-T3965 Should not allow normal user to unassign a user from a role", func() {
printer.Clean()
err := unassignUsersCmdF(s.th.Client, &cobra.Command{}, []string{model.SystemAdminRoleId, s.th.SystemAdminUser.Email})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3964 Unassign a user from a role", func(c client.Client) {
printer.Clean()
user.Roles = user.Roles + "," + model.SystemManagerRoleId
_, appErr = s.th.App.UpdateUser(s.th.Context, user, false)
s.Require().Nil(appErr)
defer func() {
user.Roles = model.SystemUserRoleId
_, appErr := s.th.App.UpdateUser(s.th.Context, user, false)
s.Require().Nil(appErr)
}()
err := unassignUsersCmdF(c, &cobra.Command{}, []string{model.SystemManagerRoleId, user.Email})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
u, err2 := s.th.App.GetUser(s.th.Context, user.Id)
s.Require().Nil(err2)
s.Require().False(u.IsInRole(model.SystemManagerRoleId))
})
}

View File

@@ -0,0 +1,277 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"net/http"
gomock "github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestAddPermissionsCmd() {
s.Run("Adding a new permission to an existing role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
newPermission := "delete"
expectedPermissions := mockRole.Permissions
expectedPermissions = append(expectedPermissions, newPermission)
expectedPatch := &model.RolePatch{
Permissions: &expectedPermissions,
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, newPermission}
err := addPermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Trying to add a new permission to a non existing role", func() {
expectedError := errors.New("role_not_found")
s.client.
EXPECT().
GetRoleByName(context.TODO(), gomock.Any()).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, expectedError).
Times(1)
args := []string{"mockRole", "newPermission"}
err := addPermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Equal(expectedError, err)
})
s.Run("Adding a new sysconsole_* permission to a role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{},
}
newPermission := "sysconsole_read_user_management_channels"
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.Run("with ancillary permissions", func() {
expectedPermissions := mockRole.Permissions
expectedPermissions = append(expectedPermissions, []string{newPermission, "read_public_channel", "read_channel", "read_public_channel_groups", "read_private_channel_groups"}...)
expectedPatch := &model.RolePatch{
Permissions: &expectedPermissions,
}
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, newPermission}
cmd := &cobra.Command{}
err := addPermissionsCmdF(s.client, cmd, args)
s.Require().Nil(err)
})
})
}
func (s *MmctlUnitTestSuite) TestRemovePermissionsCmd() {
s.Run("Removing a permission from an existing role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit", "delete"},
}
expectedPatch := &model.RolePatch{
Permissions: &[]string{"view", "edit"},
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, "delete"}
err := removePermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Removing multiple permissions from an existing role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit", "delete"},
}
expectedPatch := &model.RolePatch{
Permissions: &[]string{"edit"},
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, "view", "delete"}
err := removePermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Removing a non-existing permission from an existing role", func() {
mockRole := &model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit"},
}
expectedPatch := &model.RolePatch{
Permissions: &[]string{"view", "edit"},
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name, "delete"}
err := removePermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
s.Run("Removing a permission from a non-existing role", func() {
mockRole := model.Role{
Name: "exampleName",
}
mockError := errors.New("role_not_found")
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, mockError).
Times(1)
args := []string{mockRole.Name, "delete"}
err := removePermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().EqualError(err, "role_not_found")
})
}
func (s *MmctlUnitTestSuite) TestResetPermissionsCmd() {
s.Run("A non-existent role", func() {
mockRole := model.Role{
Name: "exampleName",
}
mockError := errors.New("role_not_found")
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(nil, &model.Response{StatusCode: http.StatusNotFound}, mockError).
Times(1)
args := []string{mockRole.Name}
err := resetPermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().EqualError(err, "role_not_found")
})
s.Run("A role without default permissions", func() {
mockRole := model.Role{
Id: "mock-id",
Name: "mock-role",
Permissions: []string{"view", "edit", "delete"},
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(&mockRole, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name}
err := resetPermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().EqualError(err, "no default permissions available for role")
})
s.Run("Resets the permissions", func() {
mockRole := model.Role{
Id: "mock-id",
Name: "channel_admin",
Permissions: []string{"view_foos", "delete_bars"},
}
expectedPermissions := []string{
"manage_channel_roles",
"use_group_mentions",
"add_bookmark_public_channel",
"edit_bookmark_public_channel",
"delete_bookmark_public_channel",
"order_bookmark_public_channel",
"add_bookmark_private_channel",
"edit_bookmark_private_channel",
"delete_bookmark_private_channel",
"order_bookmark_private_channel",
"manage_public_channel_banner",
"manage_private_channel_banner",
"manage_channel_access_rules",
"manage_public_channel_auto_translation",
"manage_private_channel_auto_translation",
"manage_private_channel_discoverability",
"manage_channel_join_requests",
}
expectedPatch := &model.RolePatch{
Permissions: &expectedPermissions,
}
s.client.
EXPECT().
GetRoleByName(context.TODO(), mockRole.Name).
Return(&mockRole, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
PatchRole(context.TODO(), mockRole.Id, expectedPatch).
Return(&model.Role{}, &model.Response{}, nil).
Times(1)
args := []string{mockRole.Name}
err := resetPermissionsCmdF(s.client, &cobra.Command{}, args)
s.Require().Nil(err)
})
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"os"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var PluginCmd = &cobra.Command{
Use: "plugin",
Short: "Management of plugins",
}
var PluginAddCmd = &cobra.Command{
Use: "add [plugins]",
Short: "Add plugins",
Long: "Add plugins to your Mattermost server.",
Example: ` plugin add hovercardexample.tar.gz pluginexample.tar.gz`,
RunE: withClient(pluginAddCmdF),
Args: cobra.MinimumNArgs(1),
}
var PluginInstallURLCmd = &cobra.Command{
Use: "install-url <url>...",
Short: "Install plugin from url",
Long: "Supply one or multiple URLs to plugins compressed in a .tar.gz file. Plugins must be enabled in the server's config settings",
Example: ` # You can install one plugin
$ mmctl plugin install-url https://example.com/mattermost-plugin.tar.gz
# Or install multiple in one go
$ mmctl plugin install-url https://example.com/mattermost-plugin-one.tar.gz https://example.com/mattermost-plugin-two.tar.gz`,
RunE: withClient(pluginInstallURLCmdF),
Args: cobra.MinimumNArgs(1),
}
var PluginDeleteCmd = &cobra.Command{
Use: "delete [plugins]",
Short: "Delete plugins",
Long: "Delete previously uploaded plugins from your Mattermost server.",
Example: ` plugin delete hovercardexample pluginexample`,
RunE: withClient(pluginDeleteCmdF),
Args: cobra.MinimumNArgs(1),
}
var PluginEnableCmd = &cobra.Command{
Use: "enable [plugins]",
Short: "Enable plugins",
Long: "Enable plugins for use on your Mattermost server.",
Example: ` plugin enable hovercardexample pluginexample`,
RunE: withClient(pluginEnableCmdF),
Args: cobra.MinimumNArgs(1),
}
var PluginDisableCmd = &cobra.Command{
Use: "disable [plugins]",
Short: "Disable plugins",
Long: "Disable plugins. Disabled plugins are immediately removed from the user interface and logged out of all sessions.",
Example: ` plugin disable hovercardexample pluginexample`,
RunE: withClient(pluginDisableCmdF),
Args: cobra.MinimumNArgs(1),
}
var PluginListCmd = &cobra.Command{
Use: "list",
Short: "List plugins",
Long: "List all enabled and disabled plugins installed on your Mattermost server.",
Example: ` plugin list`,
RunE: withClient(pluginListCmdF),
}
func init() {
PluginAddCmd.Flags().BoolP("force", "f", false, "overwrite a previously installed plugin with the same ID, if any")
PluginInstallURLCmd.Flags().BoolP("force", "f", false, "overwrite a previously installed plugin with the same ID, if any")
PluginCmd.AddCommand(
PluginAddCmd,
PluginInstallURLCmd,
PluginDeleteCmd,
PluginEnableCmd,
PluginDisableCmd,
PluginListCmd,
)
RootCmd.AddCommand(PluginCmd)
}
func pluginAddCmdF(c client.Client, cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
var multiErr *multierror.Error
for i, plugin := range args {
fileReader, err := os.Open(plugin)
if err != nil {
return err
}
if force {
_, _, err = c.UploadPluginForced(context.TODO(), fileReader)
} else {
_, _, err = c.UploadPlugin(context.TODO(), fileReader)
}
if err != nil {
printer.PrintError("Unable to add plugin: " + args[i] + ". Error: " + err.Error())
multiErr = multierror.Append(multiErr, err)
} else {
printer.Print("Added plugin: " + plugin)
}
fileReader.Close()
}
return multiErr.ErrorOrNil()
}
func pluginInstallURLCmdF(c client.Client, cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
var multiErr *multierror.Error
for _, plugin := range args {
manifest, _, err := c.InstallPluginFromURL(context.TODO(), plugin, force)
if err != nil {
printer.PrintError("Unable to install plugin from URL \"" + plugin + "\". Error: " + err.Error())
multiErr = multierror.Append(multiErr, err)
} else {
printer.PrintT("Plugin {{.Name}} successfully installed", manifest)
}
}
return multiErr.ErrorOrNil()
}
func pluginDeleteCmdF(c client.Client, cmd *cobra.Command, args []string) error {
var multiErr *multierror.Error
for _, plugin := range args {
if _, err := c.RemovePlugin(context.TODO(), plugin); err != nil {
printer.PrintError("Unable to delete plugin: " + plugin + ". Error: " + err.Error())
multiErr = multierror.Append(multiErr, fmt.Errorf("Unable to delete plugin. %w", err))
} else {
printer.Print("Deleted plugin: " + plugin)
}
}
return multiErr.ErrorOrNil()
}
func pluginEnableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
var multiErr *multierror.Error
for _, plugin := range args {
if _, err := c.EnablePlugin(context.TODO(), plugin); err != nil {
printer.PrintError("Unable to enable plugin: " + plugin + ". Error: " + err.Error())
multiErr = multierror.Append(multiErr, err)
} else {
printer.Print("Enabled plugin: " + plugin)
}
}
return multiErr.ErrorOrNil()
}
func pluginDisableCmdF(c client.Client, cmd *cobra.Command, args []string) error {
var multiErr *multierror.Error
for _, plugin := range args {
if _, err := c.DisablePlugin(context.TODO(), plugin); err != nil {
printer.PrintError("Unable to disable plugin: " + plugin + ". Error: " + err.Error())
multiErr = multierror.Append(multiErr, err)
} else {
printer.Print("Disabled plugin: " + plugin)
}
}
return multiErr.ErrorOrNil()
}
func pluginListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
pluginsResp, _, err := c.GetPlugins(context.TODO())
if err != nil {
return errors.New("Unable to list plugins. Error: " + err.Error())
}
format, _ := cmd.Flags().GetString("format")
json, _ := cmd.Flags().GetBool("json")
if format == printer.FormatJSON || json {
printer.Print(pluginsResp)
} else {
printer.Print("Listing enabled plugins")
for _, plugin := range pluginsResp.Active {
printer.PrintT("{{.Manifest.Id}}: {{.Manifest.Name}}, Version: {{.Manifest.Version}}", plugin)
}
printer.Print("")
printer.Print("Listing disabled plugins")
for _, plugin := range pluginsResp.Inactive {
printer.PrintT("{{.Manifest.Id}}: {{.Manifest.Name}}, Version: {{.Manifest.Version}}", plugin)
}
}
return nil
}

View File

@@ -0,0 +1,609 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestPluginAddCmd() {
s.SetupTestHelper().InitBasic(s.T())
pluginPath := filepath.Join(server.GetPackagePath(), "tests", "testplugin.tar.gz")
s.RunForSystemAdminAndLocal("add an already installed plugin without force", func(c client.Client) {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
})
err := pluginAddCmdF(c, &cobra.Command{}, []string{pluginPath})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
s.Require().Contains(printer.GetLines()[0], "Added plugin: ")
printer.Clean()
err = pluginAddCmdF(c, &cobra.Command{}, []string{pluginPath})
s.Require().ErrorContains(err, "Unable to install plugin. A plugin with the same ID is already installed.")
s.Require().Equal(0, len(printer.GetLines()))
s.Require().Equal(1, len(printer.GetErrorLines()))
s.Require().Contains(printer.GetErrorLines()[0], "Unable to install plugin. A plugin with the same ID is already installed.")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
// teardown
pInfo := plugins.Inactive[0]
err = pluginDeleteCmdF(c, &cobra.Command{}, []string{pInfo.Id})
s.Require().Nil(err)
})
s.RunForSystemAdminAndLocal("add an already installed plugin with force", func(c client.Client) {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
})
err := pluginAddCmdF(c, &cobra.Command{}, []string{pluginPath})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
s.Require().Contains(printer.GetLines()[0], "Added plugin: ")
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("force", true, "")
err = pluginAddCmdF(c, cmd, []string{pluginPath})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
s.Require().Equal(0, len(printer.GetErrorLines()))
s.Require().Contains(printer.GetLines()[0], "Added plugin: ")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
// teardown
pInfo := plugins.Inactive[0]
err = pluginDeleteCmdF(c, &cobra.Command{}, []string{pInfo.Id})
s.Require().Nil(err)
})
s.RunForSystemAdminAndLocal("admin and local can't add plugins if the config doesn't allow it", func(c client.Client) {
printer.Clean()
err := pluginAddCmdF(c, &cobra.Command{}, []string{pluginPath})
s.Require().ErrorContains(err, "Plugins and/or plugin uploads have been disabled.")
s.Require().Equal(1, len(printer.GetErrorLines()))
s.Require().Contains(printer.GetErrorLines()[0], "Plugins and/or plugin uploads have been disabled.")
})
s.RunForSystemAdminAndLocal("admin and local can add a plugin if the config allows it", func(c client.Client) {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
})
err := pluginAddCmdF(c, &cobra.Command{}, []string{pluginPath})
s.Require().Nil(err)
s.Require().Equal(1, len(printer.GetLines()))
s.Require().Contains(printer.GetLines()[0], "Added plugin: ")
res, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Equal(1, len(res.Inactive))
// teardown
pInfo := res.Inactive[0]
err = pluginDeleteCmdF(c, &cobra.Command{}, []string{pInfo.Id})
s.Require().Nil(err)
})
s.Run("normal user can't add plugin", func() {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
})
err := pluginAddCmdF(s.th.Client, &cobra.Command{}, []string{pluginPath})
s.Require().ErrorContains(err, "You do not have the appropriate permissions")
s.Require().Equal(1, len(printer.GetErrorLines()))
s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions")
})
}
func (s *MmctlE2ETestSuite) TestPluginInstallURLCmd() {
s.SetupTestHelper().InitBasic(s.T())
testsDir := filepath.Join(server.GetPackagePath(), "tests")
fileServer := httptest.NewServer(http.FileServer(http.Dir(testsDir)))
defer fileServer.Close()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.AllowInsecureDownloadURL = true
})
const (
plugin1ID = "testplugin"
plugin2ID = "testplugin2"
)
plugin1URL := fileServer.URL + "/testplugin.tar.gz"
plugin2URL := fileServer.URL + "/testplugin2.tar.gz"
s.RunForSystemAdminAndLocal("install new plugins", func(c client.Client) {
printer.Clean()
defer removePluginIfInstalled(c, s, plugin1ID)
defer removePluginIfInstalled(c, s, plugin2ID)
err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{plugin1URL, plugin2URL})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(plugin1ID, printer.GetLines()[0].(*model.Manifest).Id)
s.Require().Equal(plugin2ID, printer.GetLines()[1].(*model.Manifest).Id)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 2)
})
s.Run("install a plugin without permissions", func() {
printer.Clean()
defer removePluginIfInstalled(s.th.Client, s, plugin1ID)
var expected error
expected = multierror.Append(expected, errors.New("You do not have the appropriate permissions.")) //nolint:revive
err := pluginInstallURLCmdF(s.th.Client, &cobra.Command{}, []string{plugin1URL})
s.Require().ErrorContains(err, expected.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to install plugin from URL \"%s\".", plugin1URL))
s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions.")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
})
s.RunForSystemAdminAndLocal("install a nonexistent plugin", func(c client.Client) {
printer.Clean()
nonexistentURL := fileServer.URL + "/nonexistent-plugin.tar.gz"
var expected error
expected = multierror.Append(expected, errors.New("An error occurred while downloading the plugin.")) //nolint:revive
err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{nonexistentURL})
s.Require().ErrorContains(err, expected.Error())
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to install plugin from URL \"%s\".", nonexistentURL))
s.Require().Contains(printer.GetErrorLines()[0], "An error occurred while downloading the plugin.")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
})
s.RunForSystemAdminAndLocal("install an already installed plugin without force", func(c client.Client) {
printer.Clean()
defer removePluginIfInstalled(c, s, plugin1ID)
err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{plugin1URL})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(plugin1ID, printer.GetLines()[0].(*model.Manifest).Id)
err = pluginInstallURLCmdF(c, &cobra.Command{}, []string{plugin1URL})
s.Require().ErrorContains(err, "Unable to install plugin. A plugin with the same ID is already installed.")
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to install plugin from URL \"%s\".", plugin1URL))
s.Require().Contains(printer.GetErrorLines()[0], "Unable to install plugin. A plugin with the same ID is already installed.")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
})
s.RunForSystemAdminAndLocal("install an already installed plugin with force", func(c client.Client) {
printer.Clean()
defer removePluginIfInstalled(c, s, plugin1ID)
err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{plugin1URL})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(plugin1ID, printer.GetLines()[0].(*model.Manifest).Id)
cmd := &cobra.Command{}
cmd.Flags().Bool("force", true, "")
err = pluginInstallURLCmdF(c, cmd, []string{plugin1URL})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(plugin1ID, printer.GetLines()[1].(*model.Manifest).Id)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
})
}
func (s *MmctlE2ETestSuite) TestPluginDeleteCmd() {
s.SetupTestHelper().InitBasic(s.T())
testsDir := filepath.Join(server.GetPackagePath(), "tests")
fileServer := httptest.NewServer(http.FileServer(http.Dir(testsDir)))
defer fileServer.Close()
const (
pluginID = "testplugin"
dummyPluginID = "randompluginxz"
)
pluginURL := fileServer.URL + "/testplugin.tar.gz"
s.RunForSystemAdminAndLocal("Delete Plugin", func(c client.Client) {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.AllowInsecureDownloadURL = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
*cfg.PluginSettings.AllowInsecureDownloadURL = false
})
errInstall := pluginInstallURLCmdF(c, &cobra.Command{}, []string{pluginURL})
s.Require().Nil(errInstall)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(pluginID, printer.GetLines()[0].(*model.Manifest).Id)
pluginsAvail, appErrInstall := s.th.App.GetPlugins()
s.Require().Nil(appErrInstall)
s.Require().Len(pluginsAvail.Active, 0)
s.Require().Len(pluginsAvail.Inactive, 1)
err := pluginDeleteCmdF(c, &cobra.Command{}, []string{pluginID})
s.Require().Nil(err)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
})
s.RunForSystemAdminAndLocal("Delete Unknown Plugin", func(c client.Client) {
printer.Clean()
err := pluginDeleteCmdF(c, &cobra.Command{}, []string{dummyPluginID})
s.Require().ErrorContains(err, "Plugins have been disabled.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to delete plugin: %s.", dummyPluginID))
s.Require().Contains(printer.GetErrorLines()[0], "Plugins have been disabled.")
})
s.Run("Delete a Plugin without permissions", func() {
printer.Clean()
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.AllowInsecureDownloadURL = true
})
defer func() {
errDelete := pluginDeleteCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{pluginID})
s.Require().Nil(errDelete)
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
*cfg.PluginSettings.EnableUploads = false
*cfg.PluginSettings.AllowInsecureDownloadURL = false
})
}()
errInstall := pluginInstallURLCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{pluginURL})
s.Require().Nil(errInstall)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(pluginID, printer.GetLines()[0].(*model.Manifest).Id)
pluginsAvail, appErrInstall := s.th.App.GetPlugins()
s.Require().Nil(appErrInstall)
s.Require().Len(pluginsAvail.Active, 0)
s.Require().Len(pluginsAvail.Inactive, 1)
err := pluginDeleteCmdF(s.th.Client, &cobra.Command{}, []string{pluginID})
s.Require().ErrorContains(err, "You do not have the appropriate permissions.")
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to delete plugin: %s.", pluginID))
s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions.")
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
})
}
func (s *MmctlE2ETestSuite) TestPluginListCmdF() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Error when appropriate permissions are not available", func() {
printer.Clean()
enablePlugin := *s.th.App.Config().PluginSettings.Enable
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin
})
cmd := &cobra.Command{}
err := pluginListCmdF(s.th.Client, cmd, []string{})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
s.Equal("Unable to list plugins. Error: You do not have the appropriate permissions.", err.Error())
})
s.RunForSystemAdminAndLocal("Error when plugins are disabled", func(c client.Client) {
printer.Clean()
enablePlugin := *s.th.App.Config().PluginSettings.Enable
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin
})
cmd := &cobra.Command{}
err := pluginListCmdF(c, cmd, []string{})
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
s.Equal("Unable to list plugins. Error: Plugins have been disabled. Please check your logs for details.", err.Error())
})
s.RunForSystemAdminAndLocal("Success when appropriate permissions are available", func(c client.Client) {
printer.Clean()
enablePlugin := *s.th.App.Config().PluginSettings.Enable
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin
})
cmd := &cobra.Command{}
err := pluginListCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 3)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("Success when appropriate permissions are available but prints json plugins", func(c client.Client) {
printer.Clean()
enablePlugin := *s.th.App.Config().PluginSettings.Enable
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
})
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin
})
cmd := &cobra.Command{}
cmd.Flags().String("format", "json", "")
err := pluginListCmdF(c, cmd, []string{})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestPluginDisableCmd() {
s.SetupTestHelper().InitBasic(s.T())
pluginID := "testplugin"
pluginURL := filepath.Join(server.GetPackagePath(), "tests", "testplugin.tar.gz")
nonExistentPluginID := "nonExistentPluginID"
enablePlugin := *s.th.App.Config().PluginSettings.Enable
enableUploads := *s.th.App.Config().PluginSettings.EnableUploads
defer func() {
appErr := s.th.App.Channels().RemovePlugin(pluginID)
if appErr != nil {
s.Require().Contains(appErr.Error(), "Plugin is not installed.")
}
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin
*cfg.PluginSettings.EnableUploads = enableUploads
})
}()
// Enable plugin uploads
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableUploads = true
})
// Install plugin first
err := pluginAddCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{pluginURL})
s.Require().Nil(err)
s.RunForSystemAdminAndLocal("Successful disable plugin", func(c client.Client) {
printer.Clean()
appErr := s.th.App.EnablePlugin(pluginID)
s.Require().Nil(appErr)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 1)
s.Require().Len(plugins.Inactive, 0)
cmd := &cobra.Command{}
err := pluginDisableCmdF(c, cmd, []string{pluginID})
s.Require().Nil(err)
plugins, appErr = s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Disabled plugin: "+pluginID)
})
s.Run("error for disable plugin due insufficient permissions", func() {
printer.Clean()
appErr := s.th.App.EnablePlugin(pluginID)
s.Require().Nil(appErr)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 1)
s.Require().Len(plugins.Inactive, 0)
cmd := &cobra.Command{}
err := pluginDisableCmdF(s.th.Client, cmd, []string{pluginID})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "You do not have the appropriate permissions.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+pluginID+". Error: You do not have the appropriate permissions.")
plugins, appErr = s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 1)
s.Require().Len(plugins.Inactive, 0)
appErr = s.th.App.DisablePlugin(pluginID)
s.Require().Nil(appErr)
})
s.RunForSystemAdminAndLocal("error for disabling non existent plugin", func(c client.Client) {
printer.Clean()
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
cmd := &cobra.Command{}
err := pluginDisableCmdF(c, cmd, []string{nonExistentPluginID})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "Plugin is not installed.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+nonExistentPluginID+". Error: Plugin is not installed.")
plugins, appErr = s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
})
s.RunForSystemAdminAndLocal("error when plugin configs are disabled", func(c client.Client) {
printer.Clean()
appErr := s.th.App.EnablePlugin(pluginID)
s.Require().Nil(appErr)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 1)
s.Require().Len(plugins.Inactive, 0)
enablePlugin2 := *s.th.App.Config().PluginSettings.Enable
defer s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = enablePlugin2
})
s.th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
})
cmd := &cobra.Command{}
err := pluginDisableCmdF(c, cmd, []string{pluginID})
s.Require().NotNil(err)
s.Require().ErrorContains(err, "Plugins have been disabled. Please check your logs for details.")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+pluginID+". Error: Plugins have been disabled. Please check your logs for details.")
})
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var PluginMarketplaceCmd = &cobra.Command{
Use: "marketplace",
Short: "Management of marketplace plugins",
}
var PluginMarketplaceInstallCmd = &cobra.Command{
Use: "install <id>",
Short: "Install a plugin from the marketplace",
Long: "Installs a plugin listed in the marketplace server",
Example: ` plugin marketplace install jitsi`,
Args: cobra.ExactArgs(1),
RunE: withClient(pluginMarketplaceInstallCmdF),
}
var PluginMarketplaceListCmd = &cobra.Command{
Use: "list",
Short: "List marketplace plugins",
Long: "Gets all plugins from the marketplace server, merging data from locally installed plugins as well as prepackaged plugins shipped with the server",
Example: ` # You can list all the plugins
$ mmctl plugin marketplace list --all
# Pagination options can be used too
$ mmctl plugin marketplace list --page 2 --per-page 10
# Filtering will narrow down the search
$ mmctl plugin marketplace list --filter jit
# You can only retrieve local plugins
$ mmctl plugin marketplace list --local-only`,
Args: cobra.NoArgs,
RunE: withClient(pluginMarketplaceListCmdF),
}
func init() {
PluginMarketplaceListCmd.Flags().Int("page", 0, "Page number to fetch for the list of users")
PluginMarketplaceListCmd.Flags().Int("per-page", DefaultPageSize, "Number of users to be fetched")
PluginMarketplaceListCmd.Flags().Bool("all", false, "Fetch all plugins. --page flag will be ignore if provided")
PluginMarketplaceListCmd.Flags().String("filter", "", "Filter plugins by ID, name or description")
PluginMarketplaceListCmd.Flags().Bool("local-only", false, "Only retrieve local plugins")
PluginMarketplaceCmd.AddCommand(
PluginMarketplaceInstallCmd,
PluginMarketplaceListCmd,
)
PluginCmd.AddCommand(
PluginMarketplaceCmd,
)
}
func pluginMarketplaceInstallCmdF(c client.Client, _ *cobra.Command, args []string) error {
id := args[0]
pluginRequest := &model.InstallMarketplacePluginRequest{Id: id}
manifest, _, err := c.InstallMarketplacePlugin(context.TODO(), pluginRequest)
if err != nil {
return errors.Wrap(err, "couldn't install plugin from marketplace")
}
printer.PrintT("Plugin {{.Name}} successfully installed", manifest)
return nil
}
func pluginMarketplaceListCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
page, _ := cmd.Flags().GetInt("page")
perPage, _ := cmd.Flags().GetInt("per-page")
showAll, _ := cmd.Flags().GetBool("all")
filter, _ := cmd.Flags().GetString("filter")
localOnly, _ := cmd.Flags().GetBool("local-only")
if showAll {
page = 0
}
for {
pluginFilter := &model.MarketplacePluginFilter{
Page: page,
PerPage: perPage,
Filter: filter,
LocalOnly: localOnly,
}
plugins, _, err := c.GetMarketplacePlugins(context.TODO(), pluginFilter)
if err != nil {
return errors.Wrap(err, "Failed to fetch plugins")
}
if len(plugins) == 0 {
break
}
for _, plugin := range plugins {
printer.PrintT("{{.Manifest.Id}}: {{.Manifest.Name}}, Version: {{.Manifest.Version}}", plugin)
}
if !showAll {
break
}
page++
}
return nil
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"path/filepath"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestPluginMarketplaceInstallCmd() {
testsPath, found := fileutils.FindDir("tests")
s.Require().True(found, "failed to find tests directory")
prepackagedPluginsDir := "prepackaged_plugins"
os.RemoveAll(prepackagedPluginsDir)
err := os.Mkdir(prepackagedPluginsDir, os.ModePerm)
s.Require().NoError(err)
s.T().Cleanup(func() { os.RemoveAll(prepackagedPluginsDir) })
ppDir, found := fileutils.FindDir(prepackagedPluginsDir)
s.Require().True(found, "failed to find prepackaged plugins directory")
err = testlib.CopyFile(filepath.Join(testsPath, "testplugin.tar.gz"), filepath.Join(ppDir, "testplugin.tar.gz"))
s.Require().NoError(err)
err = testlib.CopyFile(filepath.Join(testsPath, "testplugin.tar.gz.asc"), filepath.Join(ppDir, "testplugin.tar.gz.sig"))
s.Require().NoError(err)
s.SetupTestHelperWithConfig(func(cfg *model.Config) {
*cfg.PluginSettings.AutomaticPrepackagedPlugins = false
cfg.PluginSettings.SignaturePublicKeyFiles = []string{
filepath.Join(testsPath, "development-public-key.asc"),
}
}).InitBasic(s.T())
s.RunForSystemAdminAndLocal("install a plugin", func(c client.Client) {
printer.Clean()
marketPlacePlugins, appErr := s.th.App.GetMarketplacePlugins(s.th.Context, &model.MarketplacePluginFilter{
Page: 0,
PerPage: 100,
Filter: "testplugin",
})
s.Require().Nil(appErr)
s.Require().NotEmpty(marketPlacePlugins)
plugin := marketPlacePlugins[0]
pluginID := plugin.Manifest.Id
pluginVersion := plugin.Manifest.Version
defer removePluginIfInstalled(c, s, pluginID)
err := pluginMarketplaceInstallCmdF(c, &cobra.Command{}, []string{pluginID})
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
manifest := printer.GetLines()[0].(*model.Manifest)
s.Require().Equal(pluginID, manifest.Id)
s.Require().Equal(pluginVersion, manifest.Version)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 1)
s.Require().Equal(pluginID, plugins.Inactive[0].Id)
s.Require().Equal(pluginVersion, plugins.Inactive[0].Version)
})
s.Run("install a plugin without permissions", func() {
printer.Clean()
const (
pluginID = "testplugin"
)
defer removePluginIfInstalled(s.th.Client, s, pluginID)
err := pluginMarketplaceInstallCmdF(s.th.Client, &cobra.Command{}, []string{pluginID})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "You do not have the appropriate permissions.")
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
})
s.RunForSystemAdminAndLocal("install a nonexistent plugin", func(c client.Client) {
printer.Clean()
const (
pluginID = "a-nonexistent-plugin"
)
defer removePluginIfInstalled(c, s, pluginID)
err := pluginMarketplaceInstallCmdF(c, &cobra.Command{}, []string{pluginID})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "Could not find the requested marketplace plugin")
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
plugins, appErr := s.th.App.GetPlugins()
s.Require().Nil(appErr)
s.Require().Len(plugins.Active, 0)
s.Require().Len(plugins.Inactive, 0)
})
}
func removePluginIfInstalled(c client.Client, s *MmctlE2ETestSuite, pluginID string) {
appErr := pluginDeleteCmdF(c, &cobra.Command{}, []string{pluginID})
if appErr != nil {
s.Require().Contains(appErr.Error(), "Unable to delete plugin.")
}
}
func (s *MmctlE2ETestSuite) TestPluginMarketplaceListCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("List Marketplace Plugins for Admin User", func(c client.Client) {
printer.Clean()
err := pluginMarketplaceListCmdF(c, &cobra.Command{}, nil)
pluginList := printer.GetLines()
// This checks whether there is an output from the command - returned list can be of length >= 0
s.Require().Len(pluginList, len(pluginList))
s.Require().NoError(err)
s.Require().Empty(printer.GetErrorLines())
})
s.Run("List Marketplace Plugins for non-admin User", func() {
printer.Clean()
err := pluginMarketplaceListCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().ErrorContains(err, "Failed to fetch plugins: You do not have the appropriate permissions.")
s.Require().Empty(printer.GetErrorLines())
s.Require().Empty(printer.GetLines())
})
}

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func createMarketplacePlugin(name string) *model.MarketplacePlugin {
return &model.MarketplacePlugin{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
Manifest: &model.Manifest{Name: name},
},
}
}
func (s *MmctlUnitTestSuite) TestPluginMarketplaceInstallCmd() {
s.Run("Install a valid plugin", func() {
printer.Clean()
id := "myplugin"
args := []string{id}
pluginRequest := &model.InstallMarketplacePluginRequest{Id: id}
manifest := &model.Manifest{Name: "My Plugin", Id: id}
s.client.
EXPECT().
InstallMarketplacePlugin(context.TODO(), pluginRequest).
Return(manifest, &model.Response{}, nil).
Times(1)
err := pluginMarketplaceInstallCmdF(s.client, &cobra.Command{}, args)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(manifest, printer.GetLines()[0])
})
s.Run("Install an invalid plugin", func() {
printer.Clean()
id := "myplugin"
args := []string{id}
pluginRequest := &model.InstallMarketplacePluginRequest{Id: id}
s.client.
EXPECT().
InstallMarketplacePlugin(context.TODO(), pluginRequest).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := pluginMarketplaceInstallCmdF(s.client, &cobra.Command{}, args)
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestPluginMarketplaceListCmd() {
s.Run("List honoring pagination flags", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("page", 0, "")
cmd.Flags().Int("per-page", 1, "")
pluginFilter := &model.MarketplacePluginFilter{Page: 0, PerPage: 1}
mockPlugin := createMarketplacePlugin("My Plugin")
plugins := []*model.MarketplacePlugin{mockPlugin}
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), pluginFilter).
Return(plugins, &model.Response{}, nil).
Times(1)
err := pluginMarketplaceListCmdF(s.client, cmd, []string{})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(mockPlugin, printer.GetLines()[0])
})
s.Run("List all plugins", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("per-page", 1, "")
cmd.Flags().Bool("all", true, "")
mockPlugin1 := createMarketplacePlugin("My Plugin One")
mockPlugin2 := createMarketplacePlugin("My Plugin Two")
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), &model.MarketplacePluginFilter{Page: 0, PerPage: 1}).
Return([]*model.MarketplacePlugin{mockPlugin1}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), &model.MarketplacePluginFilter{Page: 1, PerPage: 1}).
Return([]*model.MarketplacePlugin{mockPlugin2}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), &model.MarketplacePluginFilter{Page: 2, PerPage: 1}).
Return([]*model.MarketplacePlugin{}, &model.Response{}, nil).
Times(1)
err := pluginMarketplaceListCmdF(s.client, cmd, []string{})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(mockPlugin1, printer.GetLines()[0])
s.Require().Equal(mockPlugin2, printer.GetLines()[1])
})
s.Run("List all plugins with errors", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("per-page", 200, "")
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), &model.MarketplacePluginFilter{Page: 0, PerPage: 200}).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := pluginMarketplaceListCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 0)
})
s.Run("List honoring filter and local only flags", func() {
printer.Clean()
filter := "jit"
cmd := &cobra.Command{}
cmd.Flags().Int("per-page", 200, "")
cmd.Flags().String("filter", filter, "")
cmd.Flags().Bool("local-only", true, "")
pluginFilter := &model.MarketplacePluginFilter{Page: 0, PerPage: 200, Filter: filter, LocalOnly: true}
mockPlugin := createMarketplacePlugin("Jitsi")
plugins := []*model.MarketplacePlugin{mockPlugin}
s.client.
EXPECT().
GetMarketplacePlugins(context.TODO(), pluginFilter).
Return(plugins, &model.Response{}, nil).
Times(1)
err := pluginMarketplaceListCmdF(s.client, cmd, []string{})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(mockPlugin, printer.GetLines()[0])
})
}

View File

@@ -0,0 +1,619 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"net/http"
"os"
"strings"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestPluginAddCmd() {
s.Run("Add 1 plugin", func() {
printer.Clean()
tmpFile, err := os.CreateTemp("", "tmpPlugin")
s.Require().Nil(err)
defer os.Remove(tmpFile.Name())
pluginName := tmpFile.Name()
s.client.
EXPECT().
UploadPlugin(context.TODO(), gomock.AssignableToTypeOf(tmpFile)).
Return(&model.Manifest{}, &model.Response{}, nil).
Times(1)
err = pluginAddCmdF(s.client, &cobra.Command{}, []string{pluginName})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Added plugin: "+pluginName)
})
s.Run("Add 1 plugin, with force active", func() {
printer.Clean()
tmpFile, err := os.CreateTemp("", "tmpPlugin")
s.Require().Nil(err)
defer os.Remove(tmpFile.Name())
pluginName := tmpFile.Name()
s.client.
EXPECT().
UploadPluginForced(context.TODO(), gomock.AssignableToTypeOf(tmpFile)).
Return(&model.Manifest{}, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("force", true, "")
err = pluginAddCmdF(s.client, cmd, []string{pluginName})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Added plugin: "+pluginName)
})
s.Run("Add 1 plugin no file", func() {
printer.Clean()
err := pluginAddCmdF(s.client, &cobra.Command{}, []string{"non_existent_plugin"})
s.Require().NotNil(err)
s.Require().True(err.Error() == "open non_existent_plugin: no such file or directory" || err.Error() == "open non_existent_plugin: The system cannot find the file specified.")
})
s.Run("Add 1 plugin with error", func() {
printer.Clean()
tmpFile, err := os.CreateTemp("", "tmpPlugin")
s.Require().Nil(err)
defer os.Remove(tmpFile.Name())
pluginName := tmpFile.Name()
mockError := errors.New("plugin add error")
s.client.
EXPECT().
UploadPlugin(context.TODO(), gomock.AssignableToTypeOf(tmpFile)).
Return(&model.Manifest{}, &model.Response{}, mockError).
Times(1)
err = pluginAddCmdF(s.client, &cobra.Command{}, []string{pluginName})
s.Require().ErrorContains(err, "plugin add error")
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to add plugin: "+pluginName+". Error: "+mockError.Error())
})
s.Run("Add several plugins with some error", func() {
printer.Clean()
args := []string{"fail", "ok", "fail"}
mockError := errors.New("plugin add error")
for idx, arg := range args {
tmpFile, err := os.CreateTemp("", "tmpPlugin")
s.Require().Nil(err)
defer os.Remove(tmpFile.Name())
if arg == "fail" {
s.client.
EXPECT().
UploadPlugin(context.TODO(), gomock.AssignableToTypeOf(tmpFile)).
Return(nil, &model.Response{}, mockError).
Times(1)
} else {
s.client.
EXPECT().
UploadPlugin(context.TODO(), gomock.AssignableToTypeOf(tmpFile)).
Return(&model.Manifest{}, &model.Response{}, nil).
Times(1)
}
args[idx] = tmpFile.Name()
}
err := pluginAddCmdF(s.client, &cobra.Command{}, args)
s.Require().ErrorContains(err, "plugin add error")
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Added plugin: "+args[1])
s.Require().Len(printer.GetErrorLines(), 2)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to add plugin: "+args[0]+". Error: "+mockError.Error())
s.Require().Equal(printer.GetErrorLines()[1], "Unable to add plugin: "+args[2]+". Error: "+mockError.Error())
})
}
func (s *MmctlUnitTestSuite) TestPluginInstallUrlCmd() {
s.Run("Install multiple plugins", func() {
printer.Clean()
pluginURL1 := "https://example.com/plugin1.tar.gz"
pluginURL2 := "https://example.com/plugin2.tar.gz"
manifest1 := &model.Manifest{Name: "plugin one"}
manifest2 := &model.Manifest{Name: "plugin two"}
args := []string{pluginURL1, pluginURL2}
s.client.
EXPECT().
InstallPluginFromURL(context.TODO(), pluginURL1, false).
Return(manifest1, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
InstallPluginFromURL(context.TODO(), pluginURL2, false).
Return(manifest2, &model.Response{}, nil).
Times(1)
err := pluginInstallURLCmdF(s.client, &cobra.Command{}, args)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(manifest1, printer.GetLines()[0])
s.Require().Equal(manifest2, printer.GetLines()[1])
})
s.Run("Install one plugin, with force active", func() {
printer.Clean()
pluginURL := "https://example.com/plugin.tar.gz"
manifest := &model.Manifest{Name: "plugin name"}
s.client.
EXPECT().
InstallPluginFromURL(context.TODO(), pluginURL, true).
Return(manifest, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("force", true, "")
err := pluginInstallURLCmdF(s.client, cmd, []string{pluginURL})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(manifest, printer.GetLines()[0])
})
s.Run("Install multiple plugins, some failing", func() {
printer.Clean()
pluginURL1 := "https://example.com/plugin1.tar.gz"
pluginURL2 := "https://example.com/plugin2.tar.gz"
manifest1 := &model.Manifest{Name: "plugin one"}
args := []string{pluginURL1, pluginURL2}
s.client.
EXPECT().
InstallPluginFromURL(context.TODO(), pluginURL1, false).
Return(manifest1, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
InstallPluginFromURL(context.TODO(), pluginURL2, false).
Return(nil, &model.Response{}, errors.New("mock error")).
Times(1)
err := pluginInstallURLCmdF(s.client, &cobra.Command{}, args)
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal("Unable to install plugin from URL \"https://example.com/plugin2.tar.gz\". Error: mock error", printer.GetErrorLines()[0])
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(manifest1, printer.GetLines()[0])
})
}
func (s *MmctlUnitTestSuite) TestPluginDisableCmd() {
s.Run("Disable 1 plugin", func() {
printer.Clean()
arg := "plug1"
s.client.
EXPECT().
DisablePlugin(context.TODO(), arg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
err := pluginDisableCmdF(s.client, &cobra.Command{}, []string{arg})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Disabled plugin: "+arg)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Fail to disable 1 plugin", func() {
printer.Clean()
arg := "fail1"
mockError := errors.New("mock error")
s.client.
EXPECT().
DisablePlugin(context.TODO(), arg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
err := pluginDisableCmdF(s.client, &cobra.Command{}, []string{arg})
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+arg+". Error: "+mockError.Error())
})
s.Run("Disble several plugin with some errors", func() {
printer.Clean()
args := []string{"fail1", "plug2", "plug3", "fail4"}
mockError := errors.New("mock error")
for _, arg := range args {
if strings.HasPrefix(arg, "fail") {
s.client.
EXPECT().
DisablePlugin(context.TODO(), arg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
} else {
s.client.
EXPECT().
DisablePlugin(context.TODO(), arg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
}
}
err := pluginDisableCmdF(s.client, &cobra.Command{}, args)
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(printer.GetLines()[0], "Disabled plugin: "+args[1])
s.Require().Equal(printer.GetLines()[1], "Disabled plugin: "+args[2])
s.Require().Len(printer.GetErrorLines(), 2)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+args[0]+". Error: "+mockError.Error())
s.Require().Equal(printer.GetErrorLines()[1], "Unable to disable plugin: "+args[3]+". Error: "+mockError.Error())
})
}
func (s *MmctlUnitTestSuite) TestPluginEnableCmd() {
s.Run("Enable 1 plugin", func() {
printer.Clean()
pluginArg := "test-plugin"
s.client.
EXPECT().
EnablePlugin(context.TODO(), pluginArg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
err := pluginEnableCmdF(s.client, &cobra.Command{}, []string{pluginArg})
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+pluginArg)
})
s.Run("Enable multiple plugins", func() {
printer.Clean()
plugins := []string{"plugin1", "plugin2", "plugin3"}
for _, plugin := range plugins {
s.client.
EXPECT().
EnablePlugin(context.TODO(), plugin).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
}
err := pluginEnableCmdF(s.client, &cobra.Command{}, plugins)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 3)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+plugins[0])
s.Require().Equal(printer.GetLines()[1], "Enabled plugin: "+plugins[1])
s.Require().Equal(printer.GetLines()[2], "Enabled plugin: "+plugins[2])
})
s.Run("Fail to enable plugin", func() {
printer.Clean()
pluginArg := "fail-plugin"
mockErr := errors.New("mock error")
s.client.
EXPECT().
EnablePlugin(context.TODO(), pluginArg).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr).
Times(1)
err := pluginEnableCmdF(s.client, &cobra.Command{}, []string{pluginArg})
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to enable plugin: "+pluginArg+". Error: "+mockErr.Error())
})
s.Run("Enable multiple plugins with some having errors", func() {
printer.Clean()
okPlugins := []string{"ok-plugin-1", "ok-plugin-2"}
failPlugins := []string{"fail-plugin-1", "fail-plugin-2"}
allPlugins := okPlugins
allPlugins = append(allPlugins, failPlugins...)
mockErr := errors.New("mock error")
for _, plugin := range okPlugins {
s.client.
EXPECT().
EnablePlugin(context.TODO(), plugin).
Return(&model.Response{StatusCode: http.StatusBadRequest}, nil).
Times(1)
}
for _, plugin := range failPlugins {
s.client.
EXPECT().
EnablePlugin(context.TODO(), plugin).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr).
Times(1)
}
err := pluginEnableCmdF(s.client, &cobra.Command{}, allPlugins)
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+okPlugins[0])
s.Require().Equal(printer.GetLines()[1], "Enabled plugin: "+okPlugins[1])
s.Require().Len(printer.GetErrorLines(), 2)
s.Require().Equal(printer.GetErrorLines()[0], "Unable to enable plugin: "+failPlugins[0]+". Error: "+mockErr.Error())
s.Require().Equal(printer.GetErrorLines()[1], "Unable to enable plugin: "+failPlugins[1]+". Error: "+mockErr.Error())
})
}
func (s *MmctlUnitTestSuite) TestPluginListCmd() {
s.Run("List JSON plugins", func() {
printer.Clean()
mockList := &model.PluginsResponse{
Active: []*model.PluginInfo{
{
Manifest: model.Manifest{
Id: "id1",
Name: "name1",
Version: "v1",
},
},
{
Manifest: model.Manifest{
Id: "id2",
Name: "name2",
Version: "v2",
},
},
{
Manifest: model.Manifest{
Id: "id3",
Name: "name3",
Version: "v3",
},
},
}, Inactive: []*model.PluginInfo{
{
Manifest: model.Manifest{
Id: "id4",
Name: "name4",
Version: "v4",
},
},
{
Manifest: model.Manifest{
Id: "id5",
Name: "name5",
Version: "v5",
},
},
{
Manifest: model.Manifest{
Id: "id6",
Name: "name6",
Version: "v6",
},
},
},
}
s.client.
EXPECT().
GetPlugins(context.TODO()).
Return(mockList, &model.Response{}, nil).
Times(1)
err := pluginListCmdF(s.client, &cobra.Command{}, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 9)
s.Require().Equal("Listing enabled plugins", printer.GetLines()[0])
for i, plugin := range mockList.Active {
s.Require().Equal(plugin, printer.GetLines()[i+1])
}
s.Require().Equal("", printer.GetLines()[4])
s.Require().Equal("Listing disabled plugins", printer.GetLines()[5])
for i, plugin := range mockList.Inactive {
s.Require().Equal(plugin, printer.GetLines()[i+6])
}
})
s.Run("List Plain Plugins", func() {
printer.Clean()
printer.SetFormat(printer.FormatPlain)
defer printer.SetFormat(printer.FormatJSON)
mockList := &model.PluginsResponse{
Active: []*model.PluginInfo{
{
Manifest: model.Manifest{
Id: "id1",
Name: "name1",
Version: "v1",
},
},
{
Manifest: model.Manifest{
Id: "id2",
Name: "name2",
Version: "v2",
},
},
{
Manifest: model.Manifest{
Id: "id3",
Name: "name3",
Version: "v3",
},
},
}, Inactive: []*model.PluginInfo{
{
Manifest: model.Manifest{
Id: "id4",
Name: "name4",
Version: "v4",
},
},
{
Manifest: model.Manifest{
Id: "id5",
Name: "name5",
Version: "v5",
},
},
{
Manifest: model.Manifest{
Id: "id6",
Name: "name6",
Version: "v6",
},
},
},
}
s.client.
EXPECT().
GetPlugins(context.TODO()).
Return(mockList, &model.Response{}, nil).
Times(1)
err := pluginListCmdF(s.client, &cobra.Command{}, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 9)
s.Require().Equal("Listing enabled plugins", printer.GetLines()[0])
for i, plugin := range mockList.Active {
s.Require().Equal(plugin.Id+": "+plugin.Name+", Version: "+plugin.Version, printer.GetLines()[i+1])
}
s.Require().Equal("", printer.GetLines()[4])
s.Require().Equal("Listing disabled plugins", printer.GetLines()[5])
for i, plugin := range mockList.Inactive {
s.Require().Equal(plugin.Id+": "+plugin.Name+", Version: "+plugin.Version, printer.GetLines()[i+6])
}
})
s.Run("GetPlugins returns error", func() {
printer.Clean()
mockError := errors.New("mock error")
s.client.
EXPECT().
GetPlugins(context.TODO()).
Return(nil, &model.Response{}, mockError).
Times(1)
err := pluginListCmdF(s.client, &cobra.Command{}, nil)
s.Require().NotNil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().EqualError(err, "Unable to list plugins. Error: "+mockError.Error())
})
}
func (s *MmctlUnitTestSuite) TestPluginDeleteCmd() {
s.Run("Delete one plugin with error", func() {
printer.Clean()
args := "plugin"
mockError := errors.New("mock error")
s.client.
EXPECT().
RemovePlugin(context.TODO(), args).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
err := pluginDeleteCmdF(s.client, &cobra.Command{}, []string{args})
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal("Unable to delete plugin: "+args+". Error: "+mockError.Error(), printer.GetErrorLines()[0])
})
s.Run("Delete one plugin with no error", func() {
printer.Clean()
args := "plugin"
s.client.
EXPECT().
RemovePlugin(context.TODO(), args).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := pluginDeleteCmdF(s.client, &cobra.Command{}, []string{args})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal("Deleted plugin: "+args, printer.GetLines()[0])
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Delete several plugins", func() {
printer.Clean()
args := []string{
"plugin0",
"error1",
"error2",
"plugin3",
}
mockErrors := []error{
errors.New("mock error1"),
errors.New("mock error2"),
}
s.client.
EXPECT().
RemovePlugin(context.TODO(), args[0]).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
RemovePlugin(context.TODO(), args[1]).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErrors[0]).
Times(1)
s.client.
EXPECT().
RemovePlugin(context.TODO(), args[2]).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErrors[1]).
Times(1)
s.client.
EXPECT().
RemovePlugin(context.TODO(), args[3]).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := pluginDeleteCmdF(s.client, &cobra.Command{}, args)
s.Require().ErrorContains(err, "mock error")
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal("Deleted plugin: "+args[0], printer.GetLines()[0])
s.Require().Equal("Deleted plugin: "+args[3], printer.GetLines()[1])
s.Require().Len(printer.GetErrorLines(), 2)
s.Require().Equal("Unable to delete plugin: "+args[1]+". Error: "+mockErrors[0].Error(), printer.GetErrorLines()[0])
s.Require().Equal("Unable to delete plugin: "+args[2]+". Error: "+mockErrors[1].Error(), printer.GetErrorLines()[1])
})
}

View File

@@ -0,0 +1,446 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var PostCmd = &cobra.Command{
Use: "post",
Short: "Management of posts",
}
var PostCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a post",
Long: "Create a post in a channel or send a direct message to a user by prefixing the user with '@'.",
Example: ` post create myteam:mychannel --message "some text for the post"
post create @target-user --message "some text for the direct message"
post create myteam:mychannel --message "see attachment" --file ./report.pdf --file ./image.png`,
Args: cobra.ExactArgs(1),
RunE: withClient(postCreateCmdF),
}
var PostListCmd = &cobra.Command{
Use: "list",
Short: "List posts for a channel",
Example: ` post list myteam:mychannel
post list myteam:mychannel --number 20`,
Args: cobra.ExactArgs(1),
RunE: withClient(postListCmdF),
}
var PostRevealCmd = &cobra.Command{
Use: "reveal [post]",
Short: "Reveal a post",
Example: ` post reveal udjmt396tjghi8wnsk3a1qs1sw`,
Args: cobra.ExactArgs(1),
RunE: withClient(revealPostCmdF),
}
var PostDeleteCmd = &cobra.Command{
Use: "delete [posts]",
Short: "Mark posts as deleted or permanently delete posts with the --permanent flag",
Long: `This command will mark the post as deleted and remove it from the user's clients, but it does not permanently delete the post from the database. Please use the --permanent flag to permanently delete a post and its attachments from your database.`,
Example: ` # Mark Post as deleted
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw
# Permanently delete a post and it's file contents from the database and filestore
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw --permanent
# Permanently delete multiple posts and their file contents from the database and filestore
$ mmctl post delete udjmt396tjghi8wnsk3a1qs1sw 7jgcjt7tyjyyu83qz81wo84w6o --permanent`,
Args: cobra.MinimumNArgs(1),
RunE: withClient(deletePostsCmdF),
}
const (
ISO8601Layout = "2006-01-02T15:04:05-07:00"
PostTimeFormat = "2006-01-02 15:04:05-07:00"
directMessagePrefix = "@"
)
func init() {
PostCreateCmd.Flags().StringP("message", "m", "", "Message for the post")
PostCreateCmd.Flags().StringP("reply-to", "r", "", "Post id to reply to")
PostCreateCmd.Flags().BoolP("burn-on-read", "b", false, "Message will be deleted after a certain time after being read")
PostCreateCmd.Flags().StringArrayP("file", "f", nil, "Path to a local file to attach to the post. Can be specified multiple times to attach multiple files")
PostListCmd.Flags().IntP("number", "n", 20, "Number of messages to list")
PostListCmd.Flags().BoolP("show-ids", "i", false, "Show posts ids")
PostListCmd.Flags().BoolP("follow", "f", false, "Output appended data as new messages are posted to the channel")
PostListCmd.Flags().StringP("since", "s", "", "List messages posted after a certain time (ISO 8601)")
PostDeleteCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the post and a DB backup has been performed")
PostDeleteCmd.Flags().Bool("permanent", false, "Permanently delete the post and its contents from the database")
PostCmd.AddCommand(
PostCreateCmd,
PostListCmd,
PostDeleteCmd,
PostRevealCmd,
)
RootCmd.AddCommand(PostCmd)
}
func postCreateCmdF(c client.Client, cmd *cobra.Command, args []string) error {
if viper.GetBool("local") {
return errors.New("creating posts is not supported in local mode")
}
message, _ := cmd.Flags().GetString("message")
files, _ := cmd.Flags().GetStringArray("file")
if message == "" && len(files) == 0 {
return errors.New("a post must have a message or at least one file attachment")
}
replyTo, _ := cmd.Flags().GetString("reply-to")
if replyTo != "" {
replyToPost, _, err := c.GetPost(context.TODO(), replyTo, "")
if err != nil {
return err
}
if replyToPost.RootId != "" {
replyTo = replyToPost.RootId
}
}
channelID, err := getPostChannelID(c, args[0])
if err != nil {
return err
}
fileIDs, uploadErr, err := uploadPostFiles(c, channelID, files)
if err != nil {
// A file could not be opened, so nothing was uploaded and no
// attachments were orphaned. Abort before creating the post.
return err
}
if message == "" && len(fileIDs) == 0 {
if uploadErr != nil {
return uploadErr
}
return errors.New("a post must have a message or at least one file attachment")
}
post := &model.Post{
ChannelId: channelID,
Message: message,
RootId: replyTo,
FileIds: fileIDs,
}
if burnOnRead, _ := cmd.Flags().GetBool("burn-on-read"); burnOnRead {
post.Type = model.PostTypeBurnOnRead
}
url := "/posts" + "?set_online=false"
data, err := post.ToJSON()
if err != nil {
return fmt.Errorf("could not decode post: %w", err)
}
if _, err := c.DoAPIPost(context.TODO(), url, data); err != nil {
return fmt.Errorf("could not create post: %w", err)
}
// Any files that uploaded successfully are attached to the post above, so
// a partial batch failure never leaves those uploads orphaned. The error is
// returned last so the caller still learns which files could not be sent.
return uploadErr
}
// uploadPostFiles streams each local file to the server and returns the IDs of
// the files that were uploaded successfully. All files are opened up front so
// an unreadable path (returned as the final error) fails before any upload
// happens, leaving nothing orphaned. Once uploads start, a failure on one file
// does not abort the batch: it is collected in uploadErr while the files that
// did upload are still returned so they can be attached to the post.
func uploadPostFiles(c client.Client, channelID string, files []string) (fileIDs []string, uploadErr error, err error) {
if len(files) == 0 {
return nil, nil, nil
}
handles := make([]*os.File, 0, len(files))
defer func() {
for _, f := range handles {
f.Close()
}
}()
for _, file := range files {
f, openErr := os.Open(file)
if openErr != nil {
return nil, nil, fmt.Errorf("could not read file '%s': %w", file, openErr)
}
handles = append(handles, f)
}
var errs *multierror.Error
for i, f := range handles {
fileID, uploadFileErr := uploadPostFile(c, channelID, files[i], f)
if uploadFileErr != nil {
errs = multierror.Append(errs, uploadFileErr)
continue
}
fileIDs = append(fileIDs, fileID)
}
return fileIDs, errs.ErrorOrNil(), nil
}
func uploadPostFile(c client.Client, channelID, path string, f *os.File) (string, error) {
info, err := f.Stat()
if err != nil {
return "", fmt.Errorf("could not read file '%s': %w", path, err)
}
us, _, err := c.CreateUpload(context.TODO(), &model.UploadSession{
Type: model.UploadTypeAttachment,
ChannelId: channelID,
Filename: filepath.Base(path),
FileSize: info.Size(),
UserId: "me",
})
if err != nil {
return "", fmt.Errorf("could not upload file '%s': %w", path, err)
}
fileInfo, _, err := c.UploadData(context.TODO(), us.Id, f)
if err != nil {
return "", fmt.Errorf("could not upload file '%s': %w", path, err)
}
if fileInfo == nil {
return "", fmt.Errorf("could not upload file '%s': upload did not complete", path)
}
return fileInfo.Id, nil
}
func getPostChannelID(c client.Client, arg string) (string, error) {
if username, ok := strings.CutPrefix(arg, directMessagePrefix); ok {
channel, err := getDirectChannel(c, username)
if err != nil {
return "", err
}
return channel.Id, nil
}
channel := getChannelFromChannelArg(c, arg)
if channel == nil {
return "", errors.New("Unable to find channel '" + arg + "'")
}
return channel.Id, nil
}
func getDirectChannel(c client.Client, username string) (*model.Channel, error) {
user, err := getUserFromArg(c, username)
if err != nil {
return nil, err
}
me, _, err := c.GetMe(context.TODO(), "")
if err != nil {
return nil, fmt.Errorf("could not retrieve the current user: %w", err)
}
channel, _, err := c.CreateDirectChannel(context.TODO(), me.Id, user.Id)
if err != nil {
return nil, fmt.Errorf("could not create direct channel with '%s': %w", username, err)
}
return channel, nil
}
func eventDataToPost(eventData map[string]any) (*model.Post, error) {
post := &model.Post{}
var rawPost string
for k, v := range eventData {
if k == "post" {
rawPost = v.(string)
}
}
err := json.Unmarshal([]byte(rawPost), &post)
if err != nil {
return nil, err
}
return post, nil
}
func printPost(c client.Client, post *model.Post, usernames map[string]string, showIds, showTimestamp bool) {
var username string
if usernames[post.UserId] != "" {
username = usernames[post.UserId]
} else {
user, _, err := c.GetUser(context.TODO(), post.UserId, "")
if err != nil {
username = post.UserId
} else {
usernames[post.UserId] = user.Username
username = user.Username
}
}
postTime := model.GetTimeForMillis(post.CreateAt)
createdAt := postTime.Format(PostTimeFormat)
var templatedMessage string
if showTimestamp {
templatedMessage = fmt.Sprintf("{{ if eq .Type \"burn_on_read\" }}🔥 {{ end }}\u001b[32m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", createdAt, username)
} else {
if showIds {
templatedMessage = fmt.Sprintf("{{ if eq .Type \"burn_on_read\" }}🔥 {{ end }}\u001b[31m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", post.Id, username)
} else {
templatedMessage = fmt.Sprintf("{{ if eq .Type \"burn_on_read\" }}🔥 {{ end }}\u001b[34;1m[%s]\u001b[0m {{.Message}}", username)
}
}
if post.Type == model.PostTypeBurnOnRead {
expireAt := post.Metadata.ExpireAt
if expireAt != 0 {
dur := time.Until(time.UnixMilli(expireAt))
templatedMessage = fmt.Sprintf("%s (expires in %s)", templatedMessage, dur.String())
}
}
printer.PrintT(templatedMessage, post)
}
func getPostList(client client.Client, channelID, since string, perPage int) (*model.PostList, *model.Response, error) {
if since == "" {
return client.GetPostsForChannel(context.TODO(), channelID, 0, perPage, "", false, false)
}
sinceTime, err := time.Parse(ISO8601Layout, since)
if err != nil {
return nil, nil, fmt.Errorf("invalid since time '%s'", since)
}
sinceTimeMillis := model.GetMillisForTime(sinceTime)
return client.GetPostsSince(context.TODO(), channelID, sinceTimeMillis, false)
}
func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
number, _ := cmd.Flags().GetInt("number")
showIds, _ := cmd.Flags().GetBool("show-ids")
follow, _ := cmd.Flags().GetBool("follow")
since, _ := cmd.Flags().GetString("since")
postList, _, err := getPostList(c, channel.Id, since, number)
if err != nil {
return err
}
posts := postList.ToSlice()
showTimestamp := since != ""
usernames := map[string]string{}
for i := 1; i <= len(posts); i++ {
post := posts[len(posts)-i]
printPost(c, post, usernames, showIds, showTimestamp)
}
var multiErr *multierror.Error
if follow {
ws, err := InitWebSocketClient()
if err != nil {
return err
}
appErr := ws.Connect()
if appErr != nil {
return errors.New(appErr.Error())
}
ws.Listen()
for {
event := <-ws.EventChannel
if event.EventType() == model.WebsocketEventPosted {
post, err := eventDataToPost(event.GetData())
if err != nil {
printer.PrintError("Error parsing incoming post: " + err.Error())
multiErr = multierror.Append(multiErr, err)
}
if post.ChannelId == channel.Id {
printPost(c, post, usernames, showIds, showTimestamp)
}
}
}
}
return multiErr.ErrorOrNil()
}
func deletePostsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
permanent, err := cmd.Flags().GetBool("permanent")
if err != nil {
return err
}
confirmFlag, _ := cmd.Flags().GetBool("confirm")
if !confirmFlag && permanent {
if err = getConfirmation("Are you sure you want to delete the posts specified?", true); err != nil {
return err
}
}
var result *multierror.Error
var deleteFunc func(ctx context.Context, postID string) (*model.Response, error)
if permanent {
deleteFunc = c.PermanentDeletePost
} else {
deleteFunc = c.DeletePost
}
for _, postID := range args {
isValidId := model.IsValidId(postID)
if !isValidId {
printer.PrintError(fmt.Sprintf("Invalid postID: %s", postID))
result = multierror.Append(result, err)
continue
}
if _, err := deleteFunc(context.TODO(), postID); err != nil {
printer.PrintError(fmt.Sprintf("Error deleting post: %s. Error: %s", postID, err.Error()))
result = multierror.Append(result, err)
continue
}
printer.Print(fmt.Sprintf("%s successfully deleted", postID))
}
return result.ErrorOrNil()
}
func revealPostCmdF(c client.Client, cmd *cobra.Command, args []string) error {
postID := args[0]
post, _, err := c.RevealPost(context.TODO(), postID)
if err != nil {
return err
}
printPost(c, post, map[string]string{}, false, false)
return nil
}

View File

@@ -0,0 +1,461 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
)
func (s *MmctlE2ETestSuite) TestPostListCmd() {
s.SetupTestHelper().InitBasic(s.T())
var createNewChannelAndPosts = func() (string, *model.Post, *model.Post) {
channelName := model.NewRandomString(10)
channelDisplayName := "channelDisplayName"
channel, err := s.th.App.CreateChannel(s.th.Context, &model.Channel{Name: channelName, DisplayName: channelDisplayName, Type: model.ChannelTypePrivate, TeamId: s.th.BasicTeam.Id}, false)
s.Require().Nil(err)
post1, _, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{})
s.Require().Nil(err)
post2, _, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{})
s.Require().Nil(err)
return channelName, post1, post2
}
s.RunForSystemAdminAndLocal("List all posts for a channel", func(c client.Client) {
printer.Clean()
teamName := s.th.BasicTeam.Name
channelName, post1, post2 := createNewChannelAndPosts()
cmd := &cobra.Command{}
cmd.Flags().Int("number", 2, "")
err := postListCmdF(c, cmd, []string{teamName + ":" + channelName})
s.Require().Nil(err)
s.Equal(2, len(printer.GetLines()))
printedPost1, ok := printer.GetLines()[0].(*model.Post)
s.Require().True(ok)
s.Require().Equal(printedPost1.Message, post1.Message)
printedPost2, ok := printer.GetLines()[1].(*model.Post)
s.Require().True(ok)
s.Require().Equal(printedPost2.Message, post2.Message)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("List all posts for a channel without permissions", func() {
printer.Clean()
teamName := s.th.BasicTeam.Name
channelName, _, _ := createNewChannelAndPosts()
cmd := &cobra.Command{}
cmd.Flags().Int("number", 2, "")
err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName})
s.Require().NotNil(err)
//s.Require().Contains(err.Error(), "You do not have the appropriate permissions.")
})
s.RunForSystemAdminAndLocal("List all posts for a channel with since flag", func(c client.Client) {
printer.Clean()
ISO8601ValidString := "2006-01-02T15:04:05-07:00"
teamName := s.th.BasicTeam.Name
channelName, post1, post2 := createNewChannelAndPosts()
cmd := &cobra.Command{}
cmd.Flags().String("since", ISO8601ValidString, "")
err := postListCmdF(c, cmd, []string{teamName + ":" + channelName})
s.Require().Nil(err)
s.Equal(2, len(printer.GetLines()))
printedPost1, ok := printer.GetLines()[0].(*model.Post)
s.Require().True(ok)
s.Require().Equal(printedPost1.Message, post1.Message)
printedPost2, ok := printer.GetLines()[1].(*model.Post)
s.Require().True(ok)
s.Require().Equal(printedPost2.Message, post2.Message)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("List all posts for a channel with since flag without permissions", func() {
printer.Clean()
ISO8601ValidString := "2006-01-02T15:04:05-07:00"
teamName := s.th.BasicTeam.Name
channelName, _, _ := createNewChannelAndPosts()
cmd := &cobra.Command{}
cmd.Flags().String("since", ISO8601ValidString, "")
err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName})
s.Require().NotNil(err)
//s.Require().Contains(err.Error(), "You do not have the appropriate permissions.")
})
}
func (s *MmctlE2ETestSuite) TestPostCreateCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Create a post for System Admin Client", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Create a post for Client", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Create a post for Local Client should fail", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
prevLocal := viper.GetBool("local")
viper.Set("local", true)
defer viper.Set("local", prevLocal)
err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "creating posts is not supported in local mode")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Send a direct message for System Admin Client", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{"@" + s.th.BasicUser2.Username})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
dmChannel, appErr := s.th.App.GetOrCreateDirectChannel(s.th.Context, s.th.SystemAdminUser.Id, s.th.BasicUser2.Id)
s.Require().Nil(appErr)
posts, appErr := s.th.App.GetPosts(s.th.Context, dmChannel.Id, 0, 10)
s.Require().Nil(appErr)
var matched []*model.Post
for _, post := range posts.Posts {
if post.Message == msgArg {
matched = append(matched, post)
}
}
s.Require().Len(matched, 1, "expected exactly one direct message with the sent text")
s.Require().Equal(s.th.SystemAdminUser.Id, matched[0].UserId)
s.Require().Equal(dmChannel.Id, matched[0].ChannelId)
})
s.Run("Send a direct message for Client", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.th.Client, cmd, []string{"@" + s.th.BasicUser2.Username})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
dmChannel, appErr := s.th.App.GetOrCreateDirectChannel(s.th.Context, s.th.BasicUser.Id, s.th.BasicUser2.Id)
s.Require().Nil(appErr)
posts, appErr := s.th.App.GetPosts(s.th.Context, dmChannel.Id, 0, 10)
s.Require().Nil(appErr)
var matched []*model.Post
for _, post := range posts.Posts {
if post.Message == msgArg {
matched = append(matched, post)
}
}
s.Require().Len(matched, 1, "expected exactly one direct message with the sent text")
s.Require().Equal(s.th.BasicUser.Id, matched[0].UserId)
s.Require().Equal(dmChannel.Id, matched[0].ChannelId)
})
s.Run("Send a direct message for Local Client should fail", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
prevLocal := viper.GetBool("local")
viper.Set("local", true)
defer viper.Set("local", prevLocal)
err := postCreateCmdF(s.th.LocalClient, cmd, []string{"@" + s.th.BasicUser2.Username})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "creating posts is not supported in local mode")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Send a direct message to a non-existing user should fail", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
missingUsername := model.NewUsername()
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{"@" + missingUsername})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), missingUsername)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Reply to a an existing post for System Admin Client", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().String("reply-to", s.th.BasicPost.Id, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Reply to a an existing post for Client", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().String("reply-to", s.th.BasicPost.Id, "")
err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Reply to a an existing post for Local Client should fail", func() {
printer.Clean()
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().String("reply-to", s.th.BasicPost.Id, "")
prevLocal := viper.GetBool("local")
viper.Set("local", true)
defer viper.Set("local", prevLocal)
err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "creating posts is not supported in local mode")
s.Len(printer.GetErrorLines(), 0)
})
findPostByMessage := func(channelID, message string) *model.Post {
posts, appErr := s.th.App.GetPosts(s.th.Context, channelID, 0, 60)
s.Require().Nil(appErr)
var matched []*model.Post
for _, post := range posts.Posts {
if post.Message == message {
matched = append(matched, post)
}
}
s.Require().Len(matched, 1, "expected exactly one post with the sent text")
return matched[0]
}
s.Run("Create a post with a file attachment", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
fileContent := []byte("mmctl attachment contents")
filePath := filepath.Join(s.T().TempDir(), "attachment.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{filePath}, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
post := findPostByMessage(s.th.BasicChannel.Id, msgArg)
s.Require().Len(post.FileIds, 1)
infos, _, appErr := s.th.App.GetFileInfosForPost(s.th.Context, post, false, false)
s.Require().Nil(appErr)
s.Require().Len(infos, 1)
s.Require().Equal("attachment.txt", infos[0].Name)
s.Require().Equal(int64(len(fileContent)), infos[0].Size)
})
s.Run("Create a post with multiple file attachments", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
dir := s.T().TempDir()
firstContent := []byte("first attachment")
firstPath := filepath.Join(dir, "first.txt")
s.Require().NoError(os.WriteFile(firstPath, firstContent, 0600))
secondContent := []byte("second attachment")
secondPath := filepath.Join(dir, "second.txt")
s.Require().NoError(os.WriteFile(secondPath, secondContent, 0600))
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{firstPath, secondPath}, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
post := findPostByMessage(s.th.BasicChannel.Id, msgArg)
s.Require().Len(post.FileIds, 2)
infos, _, appErr := s.th.App.GetFileInfosForPost(s.th.Context, post, false, false)
s.Require().Nil(appErr)
s.Require().Len(infos, 2)
names := []string{infos[0].Name, infos[1].Name}
s.Require().ElementsMatch([]string{"first.txt", "second.txt"}, names)
})
s.Run("Create a post with only a file attachment and no message", func() {
printer.Clean()
fileContent := []byte("attachment only, no message")
filePath := filepath.Join(s.T().TempDir(), "only.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
cmd := &cobra.Command{}
cmd.Flags().String("message", "", "")
cmd.Flags().StringArray("file", []string{filePath}, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
posts, appErr := s.th.App.GetPosts(s.th.Context, s.th.BasicChannel.Id, 0, 60)
s.Require().Nil(appErr)
var matched []*model.Post
for _, post := range posts.Posts {
if post.Message == "" && post.Type == "" && len(post.FileIds) == 1 {
matched = append(matched, post)
}
}
s.Require().Len(matched, 1, "expected exactly one message-less post with a single file attachment")
infos, _, appErr := s.th.App.GetFileInfosForPost(s.th.Context, matched[0], false, false)
s.Require().Nil(appErr)
s.Require().Len(infos, 1)
s.Require().Equal("only.txt", infos[0].Name)
})
s.Run("Create a post with no message and no file should fail", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().String("message", "", "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "a post must have a message or at least one file attachment")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Create a post with a missing file should fail", func() {
printer.Clean()
missingPath := filepath.Join(s.T().TempDir(), "does-not-exist.txt")
cmd := &cobra.Command{}
cmd.Flags().String("message", model.NewRandomString(15), "")
cmd.Flags().StringArray("file", []string{missingPath}, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "could not read file")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("Create a post with the successful attachments when one upload fails", func() {
printer.Clean()
msgArg := model.NewRandomString(15)
dir := s.T().TempDir()
goodContent := []byte("good attachment")
goodPath := filepath.Join(dir, "good.txt")
s.Require().NoError(os.WriteFile(goodPath, goodContent, 0600))
// An empty file is rejected by the server on upload, so it exercises the
// mid-batch upload failure path while the readable file still succeeds.
emptyPath := filepath.Join(dir, "empty.txt")
s.Require().NoError(os.WriteFile(emptyPath, []byte{}, 0600))
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{goodPath, emptyPath}, "")
err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "could not upload file")
s.Len(printer.GetErrorLines(), 0)
post := findPostByMessage(s.th.BasicChannel.Id, msgArg)
s.Require().Len(post.FileIds, 1)
infos, _, appErr := s.th.App.GetFileInfosForPost(s.th.Context, post, false, false)
s.Require().Nil(appErr)
s.Require().Len(infos, 1)
s.Require().Equal("good.txt", infos[0].Name)
})
}

View File

@@ -0,0 +1,927 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"net/http"
"os"
"path/filepath"
"time"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/viper"
)
func (s *MmctlUnitTestSuite) TestPostCreateCmdF() {
s.Run("create a post with empty text and no files", func() {
cmd := &cobra.Command{}
err := postCreateCmdF(s.client, cmd, []string{"some-channel", ""})
s.Require().EqualError(err, "a post must have a message or at least one file attachment")
})
s.Run("no channel specified", func() {
msgArg := "some text"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
err := postCreateCmdF(s.client, cmd, []string{"", msgArg})
s.Require().EqualError(err, "Unable to find channel ''")
})
s.Run("wrong reply msg", func() {
msgArg := "some text"
replyToArg := "a-non-existing-post"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().String("reply-to", replyToArg, "")
s.client.
EXPECT().
GetPost(context.TODO(), replyToArg, "").
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{msgArg})
s.Require().Contains(err.Error(), "some-error")
})
s.Run("error when creating a post", func() {
msgArg := "some text"
channelArg := "example-channel"
mockChannel := model.Channel{Name: channelArg}
mockPost := &model.Post{Message: msgArg}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, errors.New("some-error")).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Contains(err.Error(), "could not create post")
})
s.Run("create a post", func() {
msgArg := "some text"
channelArg := "example-channel"
mockChannel := model.Channel{Name: channelArg}
mockPost := model.Post{Message: msgArg}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("create a post in local mode should fail", func() {
msgArg := "some text"
channelArg := "example-channel"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
prevLocal := viper.GetBool("local")
viper.Set("local", true)
defer viper.Set("local", prevLocal)
err := postCreateCmdF(s.client, cmd, []string{channelArg})
s.Require().EqualError(err, "creating posts is not supported in local mode")
})
s.Run("create a direct message", func() {
printer.Clean()
msgArg := "some text"
username := "target-user"
meID := "my-user-id"
targetUserID := "target-user-id"
dmChannelID := "dm-channel-id"
mockTargetUser := model.User{Id: targetUserID, Username: username}
mockMe := model.User{Id: meID}
mockChannel := model.Channel{Id: dmChannelID, Type: model.ChannelTypeDirect}
mockPost := model.Post{Message: msgArg, ChannelId: dmChannelID}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetUserByUsername(context.TODO(), username, "").
Return(&mockTargetUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMe(context.TODO(), "").
Return(&mockMe, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateDirectChannel(context.TODO(), meID, targetUserID).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{"@" + username})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("create a direct message with reply-to and burn-on-read", func() {
printer.Clean()
msgArg := "some text"
username := "target-user"
meID := "my-user-id"
targetUserID := "target-user-id"
dmChannelID := "dm-channel-id"
replyToArg := "reply-to-post"
rootID := "root-post-id"
mockTargetUser := model.User{Id: targetUserID, Username: username}
mockMe := model.User{Id: meID}
mockChannel := model.Channel{Id: dmChannelID, Type: model.ChannelTypeDirect}
mockReplyTo := model.Post{RootId: rootID}
mockPost := model.Post{Message: msgArg, ChannelId: dmChannelID, RootId: rootID, Type: model.PostTypeBurnOnRead}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().String("reply-to", replyToArg, "")
cmd.Flags().Bool("burn-on-read", true, "")
s.client.
EXPECT().
GetPost(context.TODO(), replyToArg, "").
Return(&mockReplyTo, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), username, "").
Return(&mockTargetUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMe(context.TODO(), "").
Return(&mockMe, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateDirectChannel(context.TODO(), meID, targetUserID).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{"@" + username})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("direct message to a non-existing user", func() {
printer.Clean()
msgArg := "some text"
username := "ghost"
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetUserByUsername(context.TODO(), username, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), username, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{"@" + username})
var nfErr ErrEntityNotFound
s.Require().ErrorAs(err, &nfErr)
s.Require().Equal("user", nfErr.Type)
s.Require().Equal(username, nfErr.ID)
})
s.Run("direct message fails to resolve the current user", func() {
printer.Clean()
msgArg := "some text"
username := "target-user"
targetUserID := "target-user-id"
mockTargetUser := model.User{Id: targetUserID, Username: username}
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetUserByUsername(context.TODO(), username, "").
Return(&mockTargetUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMe(context.TODO(), "").
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{"@" + username})
s.Require().Error(err)
s.Require().Contains(err.Error(), "could not retrieve the current user")
})
s.Run("direct message fails to create the direct channel", func() {
printer.Clean()
msgArg := "some text"
username := "target-user"
meID := "my-user-id"
targetUserID := "target-user-id"
mockTargetUser := model.User{Id: targetUserID, Username: username}
mockMe := model.User{Id: meID}
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetUserByUsername(context.TODO(), username, "").
Return(&mockTargetUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetMe(context.TODO(), "").
Return(&mockMe, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateDirectChannel(context.TODO(), meID, targetUserID).
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{"@" + username})
s.Require().Error(err)
s.Require().Contains(err.Error(), "could not create direct channel with 'target-user'")
})
s.Run("reply to an existing post", func() {
msgArg := "some text"
replyToArg := "an-existing-post"
rootID := "some-root-id"
channelArg := "example-channel"
mockChannel := model.Channel{Name: channelArg}
mockReplyTo := model.Post{RootId: rootID}
mockPost := model.Post{Message: msgArg, RootId: rootID}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("reply-to", replyToArg, "")
cmd.Flags().String("message", msgArg, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPost(context.TODO(), replyToArg, "").
Return(&mockReplyTo, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("create a post with a file attachment", func() {
printer.Clean()
msgArg := "some text"
channelArg := "example-channel"
channelID := "channel-id"
fileID := "file-id-1"
fileContent := []byte("file contents")
filePath := filepath.Join(s.T().TempDir(), "attachment.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
mockPost := model.Post{Message: msgArg, ChannelId: channelID, FileIds: []string{fileID}}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{filePath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "attachment.txt", FileSize: int64(len(fileContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(&model.FileInfo{Id: fileID}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("create a post with multiple file attachments", func() {
printer.Clean()
msgArg := "some text"
channelArg := "example-channel"
channelID := "channel-id"
dir := s.T().TempDir()
firstContent := []byte("first file")
firstPath := filepath.Join(dir, "first.txt")
s.Require().NoError(os.WriteFile(firstPath, firstContent, 0600))
secondContent := []byte("second file")
secondPath := filepath.Join(dir, "second.png")
s.Require().NoError(os.WriteFile(secondPath, secondContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
mockPost := model.Post{Message: msgArg, ChannelId: channelID, FileIds: []string{"file-id-1", "file-id-2"}}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{firstPath, secondPath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "first.txt", FileSize: int64(len(firstContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(&model.FileInfo{Id: "file-id-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "second.png", FileSize: int64(len(secondContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-2"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-2", gomock.Any()).
Return(&model.FileInfo{Id: "file-id-2"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("create a post with only a file attachment and no message", func() {
printer.Clean()
channelArg := "example-channel"
channelID := "channel-id"
fileID := "file-id-1"
fileContent := []byte("file contents")
filePath := filepath.Join(s.T().TempDir(), "attachment.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
mockPost := model.Post{ChannelId: channelID, FileIds: []string{fileID}}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", "", "")
cmd.Flags().StringArray("file", []string{filePath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "attachment.txt", FileSize: int64(len(fileContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(&model.FileInfo{Id: fileID}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("fails when an upload does not complete and message is empty", func() {
printer.Clean()
channelArg := "example-channel"
channelID := "channel-id"
fileContent := []byte("file contents")
filePath := filepath.Join(s.T().TempDir(), "attachment.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
cmd := &cobra.Command{}
cmd.Flags().String("message", "", "")
cmd.Flags().StringArray("file", []string{filePath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "attachment.txt", FileSize: int64(len(fileContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(nil, &model.Response{}, nil).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{channelArg})
s.Require().Error(err)
s.Require().Contains(err.Error(), "upload did not complete")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("creates a post with the successful attachments when a later upload errors", func() {
printer.Clean()
msgArg := "some text"
channelArg := "example-channel"
channelID := "channel-id"
dir := s.T().TempDir()
firstContent := []byte("first file")
firstPath := filepath.Join(dir, "first.txt")
s.Require().NoError(os.WriteFile(firstPath, firstContent, 0600))
secondContent := []byte("second file")
secondPath := filepath.Join(dir, "second.txt")
s.Require().NoError(os.WriteFile(secondPath, secondContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
mockPost := model.Post{Message: msgArg, ChannelId: channelID, FileIds: []string{"file-id-1"}}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{firstPath, secondPath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "first.txt", FileSize: int64(len(firstContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(&model.FileInfo{Id: "file-id-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "second.txt", FileSize: int64(len(secondContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-2"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-2", gomock.Any()).
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Error(err)
s.Require().Contains(err.Error(), "could not upload file")
s.Len(printer.GetErrorLines(), 0)
})
s.Run("fails when the file cannot be read", func() {
printer.Clean()
msgArg := "some text"
channelArg := "example-channel"
channelID := "channel-id"
missingPath := filepath.Join(s.T().TempDir(), "does-not-exist.txt")
mockChannel := model.Channel{Id: channelID, Name: channelArg}
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{missingPath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
err := postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Error(err)
s.Require().Contains(err.Error(), "could not read file")
})
s.Run("creates a post with the message when the only upload errors", func() {
printer.Clean()
msgArg := "some text"
channelArg := "example-channel"
channelID := "channel-id"
fileContent := []byte("file contents")
filePath := filepath.Join(s.T().TempDir(), "attachment.txt")
s.Require().NoError(os.WriteFile(filePath, fileContent, 0600))
mockChannel := model.Channel{Id: channelID, Name: channelArg}
mockPost := model.Post{Message: msgArg, ChannelId: channelID}
data, err := mockPost.ToJSON()
s.Require().NoError(err)
cmd := &cobra.Command{}
cmd.Flags().String("message", msgArg, "")
cmd.Flags().StringArray("file", []string{filePath}, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelArg).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
CreateUpload(context.TODO(), &model.UploadSession{Type: model.UploadTypeAttachment, ChannelId: channelID, Filename: "attachment.txt", FileSize: int64(len(fileContent)), UserId: "me"}).
Return(&model.UploadSession{Id: "upload-1"}, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UploadData(context.TODO(), "upload-1", gomock.Any()).
Return(nil, &model.Response{}, errors.New("some-error")).
Times(1)
s.client.
EXPECT().
DoAPIPost(context.TODO(), "/posts?set_online=false", data).
Return(nil, nil).
Times(1)
err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg})
s.Require().Error(err)
s.Require().Contains(err.Error(), "could not upload file")
s.Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestPostListCmdF() {
s.Run("no channel specified", func() {
sinceArg := "invalid-date"
cmd := &cobra.Command{}
cmd.Flags().String("since", sinceArg, "")
err := postListCmdF(s.client, cmd, []string{"", sinceArg})
s.Require().EqualError(err, "Unable to find channel ''")
})
s.Run("invalid time for since flag", func() {
sinceArg := "invalid-date"
mockChannel := model.Channel{Name: channelName}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("since", sinceArg, "")
err := postListCmdF(s.client, cmd, []string{channelName, sinceArg})
s.Require().Contains(err.Error(), "invalid since time 'invalid-date'")
})
s.Run("list posts for a channel", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID, CreateAt: model.GetMillisForTime(time.Now())}
mockPostList := model.NewPostList()
mockPostList.AddPost(mockPost)
mockPostList.AddOrder(mockPost.Id)
mockUser := model.User{Id: userID, Username: "some-user"}
cmd := &cobra.Command{}
cmd.Flags().Int("number", 1, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForChannel(context.TODO(), channelID, 0, 1, "", false, false).
Return(mockPostList, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), userID, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
printer.Clean()
err := postListCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], mockPost)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("list posts for a channel from a certain time (valid date)", func() {
printer.Clean()
ISO8601ValidString := "2006-01-02T15:04:05-07:00"
sinceArg := "2006-01-02T15:04:05-07:00"
sinceTime, err := time.Parse(ISO8601ValidString, sinceArg)
s.Require().Nil(err)
sinceTimeMillis := model.GetMillisForTime(sinceTime)
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID}
mockPostList := model.NewPostList()
mockPostList.AddPost(mockPost)
mockPostList.AddOrder(mockPost.Id)
mockUser := model.User{Id: userID, Username: "some-user"}
cmd := &cobra.Command{}
cmd.Flags().Int("number", 1, "")
cmd.Flags().String("since", sinceArg, "")
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsSince(context.TODO(), channelID, sinceTimeMillis, false).
Return(mockPostList, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), userID, "").
Return(&mockUser, &model.Response{}, nil).
Times(1)
err = postListCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Require().Equal(printer.GetLines()[0], mockPost)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlUnitTestSuite) TestDeletePostsCmdF() {
postID1 := "ux9bxc1b8bf1zdoj1tfu14836e"
postID2 := "ux9bxc1b8bf1zdoj1tfu14836f"
s.Run("invalid post id", func() {
id := "invalid-id"
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", false, "")
err := deletePostsCmdF(s.client, cmd, []string{id})
s.Require().Nil(err)
s.Require().Equal("Invalid postID: invalid-id", printer.GetErrorLines()[0])
})
s.Run("successfully permanently delete one post", func() {
printer.Clean()
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID1).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", true, "")
err := deletePostsCmdF(s.client, cmd, []string{postID1})
s.Require().Nil(err)
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
})
s.Run("successfully soft delete one post", func() {
printer.Clean()
s.client.
EXPECT().
DeletePost(context.TODO(), postID1).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", false, "")
err := deletePostsCmdF(s.client, cmd, []string{postID1})
s.Require().Nil(err)
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
})
s.Run("successfully delete multiple posts", func() {
printer.Clean()
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID1).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID2).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", true, "")
err := deletePostsCmdF(s.client, cmd, []string{postID1, postID2})
s.Require().Nil(err)
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
s.Require().Equal(postID2+" successfully deleted", printer.GetLines()[1])
})
s.Run("PermanentDeletePost api request returns an error", func() {
printer.Clean()
mockError := errors.New("an error occurred on deleting a post")
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID1).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", true, "")
err := deletePostsCmdF(s.client, cmd, []string{postID1})
s.Require().ErrorContains(err, "an error occurred on deleting a post")
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal("Error deleting post: "+postID1+". Error: an error occurred on deleting a post",
printer.GetErrorLines()[0])
})
s.Run("Delete multiple posts but one fails with an error", func() {
printer.Clean()
mockError := errors.New("an error occurred on deleting a post")
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID1).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
s.client.
EXPECT().
PermanentDeletePost(context.TODO(), postID2).
Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", true, "")
cmd.Flags().Bool("permanent", true, "")
err := deletePostsCmdF(s.client, cmd, []string{postID1, postID2})
s.Require().ErrorContains(err, "an error occurred on deleting a post")
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(postID1+" successfully deleted", printer.GetLines()[0])
s.Require().Equal("Error deleting post: "+postID2+". Error: an error occurred on deleting a post",
printer.GetErrorLines()[0])
})
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var ReportCmd = &cobra.Command{
Use: "report",
Short: "Reporting commands",
}
var ReportPostsCmd = &cobra.Command{
Use: "posts [channel]",
Short: "Retrieve posts for reporting purposes",
Long: `Retrieve posts from a channel for reporting purposes. This command supports
pagination and can filter posts by time range. Results can be output in JSON format
for further processing.`,
Example: ` # Get posts from a channel with default settings
mmctl report posts myteam:mychannel
# Get posts with JSON output
mmctl report posts myteam:mychannel --json
# Get posts sorted by update_at in descending order
mmctl report posts myteam:mychannel --time-field update_at --sort-direction desc
# Get posts including deleted posts and metadata
mmctl report posts myteam:mychannel --include-deleted --include-metadata
# Get posts excluding ALL system posts
mmctl report posts myteam:mychannel --exclude-system-posts
# Get more posts per page (max 1000)
mmctl report posts myteam:mychannel --per-page 500
# Resume pagination from a specific cursor (use next_cursor from previous response)
mmctl report posts myteam:mychannel --cursor "MTphYmMxMjM6Y3JlYXRlX2F0OmZhbHNlOmZhbHNlOmFzYzoxNjQwMDAwMzAwMDAwOnBvc3Qz"`,
Args: cobra.ExactArgs(1),
RunE: withClient(reportPostsCmdF),
}
func init() {
ReportPostsCmd.Flags().String("time-field", "create_at", "Time field to use for sorting (create_at or update_at)")
ReportPostsCmd.Flags().String("sort-direction", "asc", "Sort direction (asc or desc)")
ReportPostsCmd.Flags().String("cursor", "", "Opaque cursor for pagination (use next_cursor from previous response)")
ReportPostsCmd.Flags().Int("per-page", 100, "Number of posts per page (max 1000)")
ReportPostsCmd.Flags().Bool("include-deleted", false, "Include deleted posts")
ReportPostsCmd.Flags().Bool("exclude-system-posts", false, "Exclude ALL system posts (any type starting with 'system_')")
ReportPostsCmd.Flags().Bool("include-metadata", false, "Include file info, reactions, etc.")
ReportCmd.AddCommand(ReportPostsCmd)
RootCmd.AddCommand(ReportCmd)
}
func reportPostsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
channel := getChannelFromChannelArg(c, args[0])
if channel == nil {
return errors.New("Unable to find channel '" + args[0] + "'")
}
// Get flags
timeField, _ := cmd.Flags().GetString("time-field")
sortDirection, _ := cmd.Flags().GetString("sort-direction")
cursor, _ := cmd.Flags().GetString("cursor")
perPage, _ := cmd.Flags().GetInt("per-page")
includeDeleted, _ := cmd.Flags().GetBool("include-deleted")
excludeSystemPosts, _ := cmd.Flags().GetBool("exclude-system-posts")
includeMetadata, _ := cmd.Flags().GetBool("include-metadata")
// Validate flags (only validated for first request; ignored when cursor is provided)
if timeField != model.ReportingTimeFieldCreateAt && timeField != model.ReportingTimeFieldUpdateAt {
return errors.New("time-field must be either 'create_at' or 'update_at'")
}
if sortDirection != model.ReportingSortDirectionAsc && sortDirection != model.ReportingSortDirectionDesc {
return errors.New("sort-direction must be either 'asc' or 'desc'")
}
if perPage <= 0 || perPage > model.MaxReportingPerPage {
return fmt.Errorf("per-page must be between 1 and %d", model.MaxReportingPerPage)
}
// Set up options
options := model.ReportPostOptions{
ChannelId: channel.Id,
TimeField: timeField,
SortDirection: sortDirection,
PerPage: perPage,
IncludeDeleted: includeDeleted,
ExcludeSystemPosts: excludeSystemPosts,
IncludeMetadata: includeMetadata,
}
// Initialize cursor (opaque string)
cursorObj := model.ReportPostOptionsCursor{
Cursor: cursor,
}
response, _, err := c.GetPostsForReporting(context.TODO(), options, cursorObj)
if err != nil {
return fmt.Errorf("failed to get posts for reporting: %w", err)
}
// Print posts (already in correct order from API)
for _, post := range response.Posts {
printReportPost(post)
}
// Show pagination info
printer.Print(fmt.Sprintf("\nShowing %d posts.", len(response.Posts)))
if response.NextCursor != nil && response.NextCursor.Cursor != "" {
printer.Print(fmt.Sprintf("To get the next page, use: --cursor \"%s\"", response.NextCursor.Cursor))
} else {
printer.Print("No more posts available.")
}
return nil
}
func printReportPost(post *model.Post) {
jsonOutput, _ := RootCmd.Flags().GetBool("json")
if jsonOutput {
jsonBytes, err := json.MarshalIndent(post, "", " ")
if err != nil {
printer.PrintError("Error marshaling post to JSON: " + err.Error())
return
}
fmt.Println(string(jsonBytes))
} else {
// Print in a readable format
createdAt := time.Unix(post.CreateAt/1000, 0).Format("2006-01-02 15:04:05")
updatedAt := time.Unix(post.UpdateAt/1000, 0).Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stdout, "Post ID: %s\n", post.Id)
fmt.Fprintf(os.Stdout, "User ID: %s\n", post.UserId)
fmt.Fprintf(os.Stdout, "Channel ID: %s\n", post.ChannelId)
fmt.Fprintf(os.Stdout, "Created At: %s\n", createdAt)
fmt.Fprintf(os.Stdout, "Updated At: %s\n", updatedAt)
fmt.Fprintf(os.Stdout, "Message: %s\n", printer.SanitizeForTerminal(post.Message))
if post.DeleteAt > 0 {
deletedAt := time.Unix(post.DeleteAt/1000, 0).Format("2006-01-02 15:04:05")
fmt.Fprintf(os.Stdout, "Deleted At: %s\n", deletedAt)
}
fmt.Fprintf(os.Stdout, "---\n")
}
}

View File

@@ -0,0 +1,364 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build unit
package commands
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlUnitTestSuite) TestReportPostsCmdF() {
s.Run("no channel specified", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{""})
s.Require().EqualError(err, "Unable to find channel ''")
})
s.Run("invalid time-field", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "invalid_field", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().EqualError(err, "time-field must be either 'create_at' or 'update_at'")
})
s.Run("invalid sort-direction", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "invalid", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().EqualError(err, "sort-direction must be either 'asc' or 'desc'")
})
s.Run("invalid per-page (too large)", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 2000, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Contains(err.Error(), "per-page must be between 1 and")
})
s.Run("invalid per-page (zero)", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 0, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Contains(err.Error(), "per-page must be between 1 and")
})
s.Run("API error when getting posts", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockError := errors.New("api error occurred")
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForReporting(context.TODO(), model.ReportPostOptions{
ChannelId: channelID,
TimeField: "create_at",
SortDirection: "asc",
PerPage: 100,
IncludeDeleted: false,
ExcludeSystemPosts: false,
IncludeMetadata: false,
}, model.ReportPostOptionsCursor{
Cursor: "",
}).
Return(nil, &model.Response{}, mockError).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Contains(err.Error(), "failed to get posts for reporting")
})
s.Run("successfully get posts with no next cursor", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost1 := &model.Post{Id: "post1", Message: "message1", UserId: userID, ChannelId: channelID}
mockPost2 := &model.Post{Id: "post2", Message: "message2", UserId: userID, ChannelId: channelID}
mockResponse := &model.ReportPostListResponse{
Posts: []*model.Post{
mockPost1,
mockPost2,
},
NextCursor: nil,
}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForReporting(context.TODO(), model.ReportPostOptions{
ChannelId: channelID,
TimeField: "create_at",
SortDirection: "asc",
PerPage: 100,
IncludeDeleted: false,
ExcludeSystemPosts: false,
IncludeMetadata: false,
}, model.ReportPostOptionsCursor{
Cursor: "",
}).
Return(mockResponse, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("successfully get posts with next cursor", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost1 := &model.Post{Id: "post1", Message: "message1", UserId: userID, ChannelId: channelID}
nextCursor := &model.ReportPostOptionsCursor{
Cursor: "MTphYmMxMjM6Y3JlYXRlX2F0OmZhbHNlOmZhbHNlOmFzYzoxNzM1NDg4MTIzNDU2OnBvc3Qx",
}
mockResponse := &model.ReportPostListResponse{
Posts: []*model.Post{
mockPost1,
},
NextCursor: nextCursor,
}
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForReporting(context.TODO(), model.ReportPostOptions{
ChannelId: channelID,
TimeField: "create_at",
SortDirection: "asc",
PerPage: 100,
IncludeDeleted: false,
ExcludeSystemPosts: false,
IncludeMetadata: false,
}, model.ReportPostOptionsCursor{
Cursor: "",
}).
Return(mockResponse, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("successfully get posts with custom cursor", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost1 := &model.Post{Id: "post1", Message: "message1", UserId: userID, ChannelId: channelID}
mockResponse := &model.ReportPostListResponse{
Posts: []*model.Post{
mockPost1,
},
NextCursor: nil,
}
customCursor := "MTphYmMxMjM6Y3JlYXRlX2F0OmZhbHNlOmZhbHNlOmFzYzoxNzM1NDg4MDAwMDAwOmN1c3RvbXBvc3QxMjM"
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForReporting(context.TODO(), model.ReportPostOptions{
ChannelId: channelID,
TimeField: "create_at",
SortDirection: "asc",
PerPage: 100,
IncludeDeleted: false,
ExcludeSystemPosts: false,
IncludeMetadata: false,
}, model.ReportPostOptionsCursor{
Cursor: customCursor,
}).
Return(mockResponse, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "create_at", "")
cmd.Flags().String("sort-direction", "asc", "")
cmd.Flags().Int64("end-time", int64(0), "")
cmd.Flags().String("cursor", customCursor, "")
cmd.Flags().Int("per-page", 100, "")
cmd.Flags().Bool("include-deleted", false, "")
cmd.Flags().Bool("exclude-system-posts", false, "")
cmd.Flags().Bool("include-metadata", false, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
s.Run("successfully get posts with all options enabled", func() {
printer.Clean()
mockChannel := model.Channel{Name: channelName, Id: channelID}
mockPost1 := &model.Post{Id: "post1", Message: "message1", UserId: userID, ChannelId: channelID}
mockResponse := &model.ReportPostListResponse{
Posts: []*model.Post{
mockPost1,
},
NextCursor: nil,
}
customCursor := "MTphYmMxMjM6dXBkYXRlX2F0OnRydWU6dHJ1ZTpkZXNjOjE3MzU0MDAwMDAwMDA6"
s.client.
EXPECT().
GetChannel(context.TODO(), channelName).
Return(&mockChannel, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetPostsForReporting(context.TODO(), model.ReportPostOptions{
ChannelId: channelID,
TimeField: "update_at",
SortDirection: "desc",
PerPage: 500,
IncludeDeleted: true,
ExcludeSystemPosts: true,
IncludeMetadata: true,
}, model.ReportPostOptionsCursor{
Cursor: customCursor,
}).
Return(mockResponse, &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().String("time-field", "update_at", "")
cmd.Flags().String("sort-direction", "desc", "")
cmd.Flags().String("cursor", customCursor, "")
cmd.Flags().Int("per-page", 500, "")
cmd.Flags().Bool("include-deleted", true, "")
cmd.Flags().Bool("exclude-system-posts", true, "")
cmd.Flags().Bool("include-metadata", true, "")
err := reportPostsCmdF(s.client, cmd, []string{channelName})
s.Require().Nil(err)
s.Len(printer.GetErrorLines(), 0)
})
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var RolesCmd = &cobra.Command{
Use: "roles",
Short: "Manage user roles",
}
var RolesSystemAdminCmd = &cobra.Command{
Use: "system-admin [users]",
Aliases: []string{"system_admin"},
Short: "Set a user as system admin",
Long: "Make some users system admins.",
Example: ` # You can make one user a sysadmin
$ mmctl roles system-admin john_doe
# Or promote multiple users at the same time
$ mmctl roles system-admin john_doe jane_doe`,
RunE: withClient(rolesSystemAdminCmdF),
Args: cobra.MinimumNArgs(1),
}
var RolesMemberCmd = &cobra.Command{
Use: "member [users]",
Short: "Remove system admin privileges",
Long: "Remove system admin privileges from some users.",
Example: ` # You can remove admin privileges from one user
$ mmctl roles member john_doe
# Or demote multiple users at the same time
$ mmctl roles member john_doe jane_doe`,
RunE: withClient(rolesMemberCmdF),
Args: cobra.MinimumNArgs(1),
}
var RolesListCmd = &cobra.Command{
Use: "list",
Short: "List available roles",
Example: ` $ mmctl roles list
$ mmctl roles list --json`,
RunE: withClient(rolesListCmdF),
Args: cobra.NoArgs,
}
func init() {
RolesCmd.AddCommand(
RolesSystemAdminCmd,
RolesMemberCmd,
RolesListCmd,
)
RootCmd.AddCommand(RolesCmd)
}
func rolesListCmdF(c client.Client, cmd *cobra.Command, args []string) error {
roles, _, err := c.GetAllRoles(context.TODO())
if err != nil {
return fmt.Errorf("failed to get roles: %w", err)
}
for _, role := range roles {
printer.PrintT("{{.Name}}", role)
}
return nil
}
func rolesSystemAdminCmdF(c client.Client, _ *cobra.Command, args []string) error {
var errs *multierror.Error
users := getUsersFromUserArgs(c, args)
for i, user := range users {
if user == nil {
userErr := fmt.Errorf("unable to find user %q", args[i])
errs = multierror.Append(errs, userErr)
printer.PrintError(userErr.Error())
continue
}
systemAdmin := false
roles := strings.Fields(user.Roles)
for _, role := range roles {
if role == model.SystemAdminRoleId {
systemAdmin = true
}
}
if !systemAdmin {
roles = append(roles, model.SystemAdminRoleId)
if _, err := c.UpdateUserRoles(context.TODO(), user.Id, strings.Join(roles, " ")); err != nil {
updateErr := fmt.Errorf("can't update roles for user %q: %w", args[i], err)
errs = multierror.Append(errs, updateErr)
printer.PrintError(updateErr.Error())
continue
}
printer.Print(fmt.Sprintf("System admin role assigned to user %q. Current roles are: %s", args[i], strings.Join(roles, ", ")))
}
}
return errs.ErrorOrNil()
}
func rolesMemberCmdF(c client.Client, _ *cobra.Command, args []string) error {
var errs *multierror.Error
users := getUsersFromUserArgs(c, args)
for i, user := range users {
if user == nil {
userErr := fmt.Errorf("unable to find user %q", args[i])
errs = multierror.Append(errs, userErr)
printer.PrintError(userErr.Error())
continue
}
shouldRemoveSysadmin := false
var newRoles []string
roles := strings.FieldsSeq(user.Roles)
for role := range roles {
switch role {
case model.SystemAdminRoleId:
shouldRemoveSysadmin = true
default:
newRoles = append(newRoles, role)
}
}
if shouldRemoveSysadmin {
if _, err := c.UpdateUserRoles(context.TODO(), user.Id, strings.Join(newRoles, " ")); err != nil {
updateErr := fmt.Errorf("can't update roles for user %q: %w", args[i], err)
errs = multierror.Append(errs, updateErr)
printer.PrintError(updateErr.Error())
continue
}
printer.Print(fmt.Sprintf("System admin role revoked for user %q. Current roles are: %s", args[i], strings.Join(newRoles, ", ")))
}
}
return errs.ErrorOrNil()
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlE2ETestSuite) TestRolesListCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.RunForSystemAdminAndLocal("List all available roles", func(c client.Client) {
printer.Clean()
err := rolesListCmdF(c, &cobra.Command{}, []string{})
s.Require().Nil(err)
lines := printer.GetLines()
s.Require().NotEmpty(lines, "Should return at least one role")
// Verify all returned items are valid Role objects with names
for i, line := range lines {
role, ok := line.(*model.Role)
s.Require().True(ok, "Line %d should be a *model.Role object", i)
s.Require().NotEmpty(role.Name, "Role %d should have a non-empty name", i)
}
s.Len(printer.GetErrorLines(), 0, "Should not have any error output")
})
s.Run("List roles without permissions should fail", func() {
printer.Clean()
err := rolesListCmdF(s.th.Client, &cobra.Command{}, []string{})
s.Require().Error(err)
s.Require().Contains(err.Error(), "failed to get roles")
})
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func (s *MmctlUnitTestSuite) TestRolesListPlain() {
s.Run("Prints all role names", func() {
printer.Clean()
roles := []*model.Role{
{Id: "r1", Name: "system_admin"},
{Id: "r2", Name: "system_user"},
}
s.client.
EXPECT().
GetAllRoles(context.TODO()).
Return(roles, &model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := rolesListCmdF(s.client, nil, nil)
s.Require().NoError(err)
lines := printer.GetLines()
s.Require().NotEmpty(lines)
})
}
func (s *MmctlUnitTestSuite) TestRolesListJSON() {
s.Run("prints JSON list of all role objects", func() {
printer.Clean()
roles := []*model.Role{
{Id: "r1", Name: "system_admin"},
{Id: "r2", Name: "system_user"},
}
s.client.
EXPECT().
GetAllRoles(context.TODO()).
Return(roles, &model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
viper.Set("json", true)
defer viper.Set("json", false)
err := rolesListCmdF(s.client, nil, nil)
s.Require().NoError(err)
lines := printer.GetLines()
s.Require().NotEmpty(lines)
_, jerr := json.Marshal(lines)
s.Require().NoError(jerr)
})
}
func (s *MmctlUnitTestSuite) TestRolesListPermissionError() {
s.Run("Test permission denied message for getting roles", func() {
printer.Clean()
forbiddenErr := fmt.Errorf("forbidden")
s.client.
EXPECT().
GetAllRoles(context.TODO()).
Return(nil, &model.Response{StatusCode: http.StatusForbidden}, forbiddenErr).
Times(1)
err := rolesListCmdF(s.client, nil, nil)
s.Require().Error(err)
s.Require().Contains(err.Error(), "failed to get roles")
})
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestMakeAdminCmd() {
s.Run("Add admin privileges to user", func() {
printer.Clean()
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"}
newRoles := "system_user system_admin"
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, newRoles).
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(fmt.Sprintf("System admin role assigned to user %q. Current roles are: %s", mockUser.Email, "system_user, system_admin"), printer.GetLines()[0])
})
s.Run("Adding admin privileges to existing admin", func() {
printer.Clean()
roles := "system_user system_admin"
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: roles}
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Add admin to non existing user", func() {
printer.Clean()
emailArg := "doesnotexist@example.com"
s.client.
EXPECT().
GetUserByEmail(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{emailArg})
s.Require().ErrorContains(err, "unable to find user")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(fmt.Sprintf("unable to find user %q", emailArg), printer.GetErrorLines()[0])
})
s.Run("Error while updating admin role", func() {
printer.Clean()
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"}
newRoles := "system_user system_admin"
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, newRoles).
Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")).
Times(1)
err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().ErrorContains(err, "can't update roles for user")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("can't update roles for user %q", mockUser.Email))
})
}
func (s *MmctlUnitTestSuite) TestMakeMemberCmd() {
s.Run("Remove admin privileges for admin", func() {
printer.Clean()
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user system_admin"}
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, "system_user").
Return(&model.Response{StatusCode: http.StatusOK}, nil).
Times(1)
err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Equal(fmt.Sprintf("System admin role revoked for user %q. Current roles are: %s", mockUser.Email, "system_user"), printer.GetLines()[0])
})
s.Run("Remove admin privileges from non admin user", func() {
printer.Clean()
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"}
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Error while revoking admin role", func() {
printer.Clean()
mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user system_admin"}
s.client.
EXPECT().
GetUserByEmail(context.TODO(), mockUser.Email, "").
Return(mockUser, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
UpdateUserRoles(context.TODO(), mockUser.Id, "system_user").
Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")).
Times(1)
err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email})
s.Require().ErrorContains(err, "can't update roles for user")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("can't update roles for user %q", mockUser.Email))
})
s.Run("Remove admin from non existing user", func() {
printer.Clean()
emailArg := "doesnotexist@example.com"
s.client.
EXPECT().
GetUserByEmail(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUserByUsername(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
s.client.
EXPECT().
GetUser(context.TODO(), emailArg, "").
Return(nil, &model.Response{}, nil).
Times(1)
err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{emailArg})
s.Require().ErrorContains(err, "unable to find user")
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 1)
s.Require().Equal(fmt.Sprintf("unable to find user %q", emailArg), printer.GetErrorLines()[0])
})
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"net/url"
"os"
"path/filepath"
"runtime/debug"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
func Run(args []string) error {
viper.SetEnvPrefix("mmctl")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.SetDefault("local-socket-path", model.LocalModeSocketPath)
viper.AutomaticEnv()
RootCmd.PersistentFlags().String("config", filepath.Join(xdgConfigHomeVar, configParent, configFileName), "path to the configuration file")
_ = viper.BindPFlag("config", RootCmd.PersistentFlags().Lookup("config"))
RootCmd.PersistentFlags().Bool("suppress-warnings", false, "disables printing warning messages")
_ = viper.BindPFlag("suppress-warnings", RootCmd.PersistentFlags().Lookup("suppress-warnings"))
RootCmd.PersistentFlags().String("format", "plain", "the format of the command output [plain, json]")
_ = viper.BindPFlag("format", RootCmd.PersistentFlags().Lookup("format"))
_ = RootCmd.PersistentFlags().MarkHidden("format")
RootCmd.PersistentFlags().Bool("json", false, "the output format will be in json format")
_ = viper.BindPFlag("json", RootCmd.PersistentFlags().Lookup("json"))
RootCmd.PersistentFlags().Bool("strict", false, "will only run commands if the mmctl version matches the server one")
_ = viper.BindPFlag("strict", RootCmd.PersistentFlags().Lookup("strict"))
RootCmd.PersistentFlags().Bool("insecure-sha1-intermediate", false, "allows to use insecure TLS protocols, such as SHA-1")
_ = viper.BindPFlag("insecure-sha1-intermediate", RootCmd.PersistentFlags().Lookup("insecure-sha1-intermediate"))
RootCmd.PersistentFlags().Bool("insecure-tls-version", false, "allows to use TLS versions 1.0 and 1.1")
_ = viper.BindPFlag("insecure-tls-version", RootCmd.PersistentFlags().Lookup("insecure-tls-version"))
RootCmd.PersistentFlags().Bool("local", false, "allows communicating with the server through a unix socket")
_ = viper.BindPFlag("local", RootCmd.PersistentFlags().Lookup("local"))
RootCmd.PersistentFlags().Bool("short-stat", false, "short stat will provide useful statistical data")
_ = RootCmd.PersistentFlags().MarkHidden("short-stat")
RootCmd.PersistentFlags().Bool("no-stat", false, "the statistical data won't be displayed")
_ = RootCmd.PersistentFlags().MarkHidden("no-stat")
RootCmd.PersistentFlags().Bool("disable-pager", false, "disables paged output")
_ = viper.BindPFlag("disable-pager", RootCmd.PersistentFlags().Lookup("disable-pager"))
RootCmd.PersistentFlags().Bool("quiet", false, "prevent mmctl to generate output for the commands")
_ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet"))
RootCmd.SetArgs(args)
defer func() {
if x := recover(); x != nil {
printPanic(x)
_ = printer.Flush()
os.Exit(1)
}
}()
err := RootCmd.Execute()
// Flush the printer first before printing any error
_ = printer.Flush()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
}
return err
}
func printPanic(x any) {
u, err := url.Parse("https://github.com/mattermost/mattermost/issues/new")
if err != nil {
panic(err)
}
q := u.Query()
q.Add("title", "[mmctl] [bug] panic on v"+Version)
q.Add("body", "<!--- Please provide the stack trace -->\n")
u.RawQuery = q.Encode()
printer.PrintError("Uh oh! Something unexpected happened :( Would you mind reporting it?")
printer.PrintError(u.String() + "\n")
printer.PrintError(fmt.Sprintf("%s", x))
printer.PrintError(string(debug.Stack()))
}
var RootCmd = &cobra.Command{
Use: "mmctl",
Short: "Remote client for the Open Source, self-hosted Slack-alternative",
Long: `Mattermost offers workplace messaging across web, PC and phones with archiving, search and integration with your existing systems. Documentation available at https://docs.mattermost.com`,
DisableAutoGenTag: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
for i, arg := range args {
args[i] = strings.TrimSpace(arg)
}
format := viper.GetString("format")
if viper.GetBool("disable-pager") {
printer.OverrideEnablePager(false)
}
printer.SetCommand(cmd)
isJSON := viper.GetBool("json")
if isJSON || format == printer.FormatJSON {
printer.SetFormat(printer.FormatJSON)
} else {
printer.SetFormat(printer.FormatPlain)
}
quiet := viper.GetBool("quiet")
printer.SetQuiet(quiet)
perPage, err := cmd.Flags().GetInt("per-page")
if err == nil && perPage > MaxPageSize {
printer.PrintError(fmt.Sprintf("Per page value is greater than the maximum allowed. Mattermost might only return %d items.", MaxPageSize))
}
},
SilenceUsage: true,
SilenceErrors: true,
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func executeRawCommand(root *cobra.Command, args string) (c *cobra.Command, output string, err error) {
actual := new(bytes.Buffer)
RootCmd.SetOut(actual)
RootCmd.SetErr(actual)
RootCmd.SetArgs(strings.Split(args, " "))
c, err = RootCmd.ExecuteC()
return c, actual.String(), err
}
func TestRootRecover(t *testing.T) {
printPanic("some panic")
lines := printer.GetErrorLines()
assert.Equal(t, "Uh oh! Something unexpected happened :( Would you mind reporting it?", lines[0])
assert.True(t, strings.HasPrefix(lines[1], "https://github.com/mattermost/mattermost/issues/new?body="))
assert.Equal(t, "some panic", lines[2])
assert.True(t, strings.HasPrefix(lines[3], "goroutine "))
}
func (s *MmctlUnitTestSuite) TestArgumentsHaveWhitespaceTrimmed() {
arguments := []string{"value_1", "value_2"}
lineEndings := []string{"\n", "\r", "\r\n"}
prettyNames := []string{"-n", "-r", "-r-n"}
commandCalled := false
for i, lineEnding := range lineEndings {
testName := fmt.Sprintf("Commands have their arguments stripped of whitespace[%s]", prettyNames[i])
s.Run(testName, func() {
commandCalled = false
commandFunction := func(command *cobra.Command, args []string) {
commandCalled = true
s.Equal(arguments, args, "Expected arguments to have their whitespace trimmed")
}
mockCommand := &cobra.Command{Use: "test", Run: commandFunction}
commandString := strings.Join([]string{"test", " ", arguments[0], lineEnding, " ", arguments[1], lineEnding}, "")
RootCmd.AddCommand(mockCommand)
_, _, err := executeRawCommand(RootCmd, commandString)
s.Require().NoError(err)
s.Require().True(commandCalled, "Expected mock command to be called")
})
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var SamlCmd = &cobra.Command{
Use: "saml",
Short: "SAML related utilities",
}
var SamlAuthDataResetCmd = &cobra.Command{
Use: "auth-data-reset",
Short: "Reset AuthData field to Email",
Long: "Resets the AuthData field for SAML users to their email. Run this utility after setting the 'id' SAML attribute to an empty value.",
Example: ` # Reset all SAML users' AuthData field to their email, including deleted users
$ mmctl saml auth-data-reset --include-deleted
# Show how many users would be affected by the reset
$ mmctl saml auth-data-reset --dry-run
# Skip confirmation for resetting the AuthData
$ mmctl saml auth-data-reset -y
# Only reset the AuthData for the following SAML users
$ mmctl saml auth-data-reset --users userid1,userid2`,
RunE: withClient(samlAuthDataResetCmdF),
}
func init() {
SamlAuthDataResetCmd.Flags().Bool("include-deleted", false, "Include deleted users")
SamlAuthDataResetCmd.Flags().Bool("dry-run", false, "Dry run only")
SamlAuthDataResetCmd.Flags().BoolP("yes", "y", false, "Skip confirmation")
SamlAuthDataResetCmd.Flags().StringSlice("users", nil, "Comma-separated list of user IDs to which the operation will be applied")
SamlCmd.AddCommand(
SamlAuthDataResetCmd,
)
RootCmd.AddCommand(SamlCmd)
}
func samlAuthDataResetCmdF(c client.Client, cmd *cobra.Command, args []string) error {
includeDeleted, _ := cmd.Flags().GetBool("include-deleted")
dryRun, _ := cmd.Flags().GetBool("dry-run")
confirmed, _ := cmd.Flags().GetBool("yes")
userIDs, _ := cmd.Flags().GetStringSlice("users")
if !dryRun && !confirmed {
if err := getConfirmation("This action is irreversible. Are you sure you want to continue?", false); err != nil {
return err
}
}
numAffected, _, err := c.ResetSamlAuthDataToEmail(context.TODO(), includeDeleted, dryRun, userIDs)
if err != nil {
return err
}
if dryRun {
printer.Print(fmt.Sprintf("%d user records would be affected.\n", numAffected))
} else {
printer.Print(fmt.Sprintf("%d user records were changed.\n", numAffected))
}
return nil
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestSamlAuthDataReset() {
s.Run("Reset auth data without confirmation returns an error", func() {
cmd := &cobra.Command{}
err := samlAuthDataResetCmdF(s.client, cmd, nil)
s.Require().NotNil(err)
s.Require().EqualError(err, "could not proceed, either enable --confirm flag or use an interactive shell to complete operation: this is not an interactive shell")
})
s.Run("Reset auth data without errors", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Bool("yes", true, "")
outputMessage := "1 user records were changed.\n"
s.client.
EXPECT().
ResetSamlAuthDataToEmail(context.TODO(), false, false, []string{}).
Return(int64(1), &model.Response{}, nil).
Times(1)
err := samlAuthDataResetCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], outputMessage)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Reset auth data dry run", func() {
printer.Clean()
outputMessage := "1 user records would be affected.\n"
cmd := &cobra.Command{}
cmd.Flags().Bool("dry-run", true, "")
s.client.
EXPECT().
ResetSamlAuthDataToEmail(context.TODO(), false, true, []string{}).
Return(int64(1), &model.Response{}, nil).
Times(1)
err := samlAuthDataResetCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], outputMessage)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.Run("Reset auth data with specified users", func() {
printer.Clean()
users := []string{"user1"}
s.client.
EXPECT().
ResetSamlAuthDataToEmail(context.TODO(), false, false, users).
Return(int64(1), &model.Response{}, nil).
Times(1)
cmd := &cobra.Command{}
cmd.Flags().Bool("yes", true, "")
cmd.Flags().StringSlice("users", users, "")
err := samlAuthDataResetCmdF(s.client, cmd, nil)
s.Require().Nil(err)
s.Require().Len(printer.GetErrorLines(), 0)
})
}

View File

@@ -0,0 +1,412 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//nolint:gosec
package commands
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"slices"
"time"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/v8/channels/app/imports"
"github.com/mattermost/mattermost/server/public/model"
pUtils "github.com/mattermost/mattermost/server/public/utils"
"github.com/icrowley/fake"
"github.com/spf13/cobra"
)
const (
deactivatedUser = "deactivated"
guestUser = "guest"
attachmentsDir = "attachments"
)
var SampledataCmd = &cobra.Command{
Use: "sampledata",
Short: "Generate sample data",
Long: "Generate a sample data file and store it locally, or directly import it to the remote server",
Example: ` # you can create a sampledata file and store it locally
$ mmctl sampledata --bulk sampledata-file.jsonl
# or you can simply print it to the stdout
$ mmctl sampledata --bulk -
# the amount of entities to create can be customized
$ mmctl sampledata -t 7 -u 20 -g 4
# the sampledata file can be directly imported in the remote server by not specifying a --bulk flag
$ mmctl sampledata
# and the sample users can be created with profile pictures
$ mmctl sampledata --profile-images ./images/profiles`,
Args: cobra.NoArgs,
RunE: withClient(sampledataCmdF),
}
func init() {
SampledataCmd.Flags().Int64P("seed", "s", 1, "Seed used for generating the random data (Different seeds generate different data).")
SampledataCmd.Flags().IntP("teams", "t", 2, "The number of sample teams.")
SampledataCmd.Flags().Int("channels-per-team", 10, "The number of sample channels per team.")
SampledataCmd.Flags().IntP("users", "u", 15, "The number of sample users.")
SampledataCmd.Flags().IntP("guests", "g", 1, "The number of sample guests.")
SampledataCmd.Flags().Int("deactivated-users", 0, "The number of deactivated users.")
SampledataCmd.Flags().Int("team-memberships", 2, "The number of sample team memberships per user.")
SampledataCmd.Flags().Int("channel-memberships", 5, "The number of sample channel memberships per user in a team.")
SampledataCmd.Flags().Int("posts-per-channel", 100, "The number of sample post per channel.")
SampledataCmd.Flags().Int("direct-channels", 30, "The number of sample direct message channels.")
SampledataCmd.Flags().Int("posts-per-direct-channel", 15, "The number of sample posts per direct message channel.")
SampledataCmd.Flags().Int("group-channels", 15, "The number of sample group message channels.")
SampledataCmd.Flags().Int("posts-per-group-channel", 30, "The number of sample posts per group message channel.")
SampledataCmd.Flags().String("profile-images", "", "Optional. Path to folder with images to randomly pick as user profile image.")
SampledataCmd.Flags().StringP("bulk", "b", "", "Optional. Path to write a JSONL bulk file instead of uploading into the remote server.")
RootCmd.AddCommand(SampledataCmd)
}
func uploadAndProcess(c client.Client, zipPath string, isLocal bool) error {
zipFile, err := os.Open(zipPath)
if err != nil {
return fmt.Errorf("cannot open import file %q: %w", zipPath, err)
}
defer zipFile.Close()
info, err := zipFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat import file: %w", err)
}
userID := "me"
if isLocal {
userID = model.UploadNoUserID
}
// create session
us, _, err := c.CreateUpload(context.TODO(), &model.UploadSession{
Filename: info.Name(),
FileSize: info.Size(),
Type: model.UploadTypeImport,
UserId: userID,
})
if err != nil {
return fmt.Errorf("failed to create upload session: %w", err)
}
printer.PrintT("Upload session successfully created, ID: {{.Id}} ", us)
// upload file
finfo, _, err := c.UploadData(context.TODO(), us.Id, zipFile)
if err != nil {
return fmt.Errorf("failed to upload data: %w", err)
}
printer.PrintT("Import file successfully uploaded, name: {{.Name}}", finfo)
// process
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: model.JobTypeImportProcess,
Data: map[string]string{
"import_file": us.Id + "_" + finfo.Name,
},
})
if err != nil {
return fmt.Errorf("failed to create import process job: %w", err)
}
printer.PrintT("Import process job successfully created, ID: {{.Id}}", job)
for {
job, _, err = c.GetJob(context.TODO(), job.Id)
if err != nil {
return fmt.Errorf("failed to get import job status: %w", err)
}
if job.Status != model.JobStatusPending && job.Status != model.JobStatusInProgress {
break
}
time.Sleep(500 * time.Millisecond)
}
if job.Status != model.JobStatusSuccess {
return fmt.Errorf("job reported non-success status: %s", job.Status)
}
printer.PrintT("Sampledata successfully processed", job)
return nil
}
func processProfileImagesDir(profileImagesPath, tmpDir, bulk string) ([]string, error) {
profileImages := []string{}
var profileImagesStat os.FileInfo
profileImagesStat, err := os.Stat(profileImagesPath)
if os.IsNotExist(err) {
return nil, fmt.Errorf("profile images folder doesn't exist")
}
if !profileImagesStat.IsDir() {
return nil, fmt.Errorf("profile-images parameters must be a directory")
}
profileImagesFiles, err := os.ReadDir(profileImagesPath)
if err != nil {
return nil, fmt.Errorf("invalid profile-images parameter: %w", err)
}
// we need to copy the images to be part of the import zip
if bulk == "" {
for _, profileImage := range profileImagesFiles {
profileImageSrc := filepath.Join(profileImagesPath, profileImage.Name())
profileImagePath := filepath.Join(attachmentsDir, profileImage.Name())
profileImageDst := filepath.Join(tmpDir, profileImagePath)
if err := pUtils.CopyFile(profileImageSrc, profileImageDst); err != nil {
return nil, fmt.Errorf("cannot copy file %q to %q: %w", profileImageSrc, profileImageDst, err)
}
// the path we use in the profile info is relative to the zipfile base
profileImages = append(profileImages, profileImagePath)
}
// we're not importing the resulting file, so we keep the
// image paths corresponding to the value of the flag
} else {
for _, profileImage := range profileImagesFiles {
profileImages = append(profileImages, filepath.Join(profileImagesPath, profileImage.Name()))
}
}
slices.Sort(profileImages)
return profileImages, nil
}
//nolint:gocyclo
func sampledataCmdF(c client.Client, command *cobra.Command, args []string) error {
seed, _ := command.Flags().GetInt64("seed")
bulk, _ := command.Flags().GetString("bulk")
teams, _ := command.Flags().GetInt("teams")
channelsPerTeam, _ := command.Flags().GetInt("channels-per-team")
users, _ := command.Flags().GetInt("users")
deactivatedUsers, _ := command.Flags().GetInt("deactivated-users")
guests, _ := command.Flags().GetInt("guests")
teamMemberships, _ := command.Flags().GetInt("team-memberships")
channelMemberships, _ := command.Flags().GetInt("channel-memberships")
postsPerChannel, _ := command.Flags().GetInt("posts-per-channel")
directChannels, _ := command.Flags().GetInt("direct-channels")
postsPerDirectChannel, _ := command.Flags().GetInt("posts-per-direct-channel")
groupChannels, _ := command.Flags().GetInt("group-channels")
postsPerGroupChannel, _ := command.Flags().GetInt("posts-per-group-channel")
profileImagesPath, _ := command.Flags().GetString("profile-images")
withAttachments := profileImagesPath != ""
if teamMemberships > teams {
return fmt.Errorf("you can't have more team memberships than teams")
}
if channelMemberships > channelsPerTeam {
return fmt.Errorf("you can't have more channel memberships than channels per team")
}
if users < 6 && groupChannels > 0 {
return fmt.Errorf("you can't have group channels generation with less than 6 users. Use --group-channels 0 or increase the number of users")
}
var bulkFile *os.File
var tmpDir string
var err error
switch bulk {
case "":
tmpDir, err = os.MkdirTemp("", "mmctl-sampledata-")
if err != nil {
return fmt.Errorf("unable to create temporary directory")
}
defer os.RemoveAll(tmpDir)
if withAttachments {
if err = os.Mkdir(filepath.Join(tmpDir, attachmentsDir), 0755); err != nil {
return fmt.Errorf("cannot create attachments directory: %w", err)
}
}
bulkFile, err = os.Create(filepath.Join(tmpDir, "import.jsonl"))
if err != nil {
return fmt.Errorf("unable to open temporary file: %w", err)
}
defer bulkFile.Close()
case "-":
bulkFile = os.Stdout
default:
bulkFile, err = os.OpenFile(bulk, os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
return fmt.Errorf("unable to write into the %q file: %w", bulk, err)
}
defer bulkFile.Close()
}
profileImages := []string{}
if profileImagesPath != "" {
profileImages, err = processProfileImagesDir(profileImagesPath, tmpDir, bulk)
if err != nil {
return fmt.Errorf("cannot process profile images directory: %w", err)
}
}
encoder := json.NewEncoder(bulkFile)
version := 1
if err := encoder.Encode(imports.LineImportData{Type: "version", Version: &version}); err != nil {
return fmt.Errorf("could not encode version line: %w", err)
}
fake.Seed(seed)
rand.Seed(seed)
teamsAndChannels := make(map[string][]string, teams)
for i := range teams {
teamLine := createTeam(i)
teamsAndChannels[*teamLine.Team.Name] = []string{}
if err := encoder.Encode(teamLine); err != nil {
return fmt.Errorf("could not encode team line: %w", err)
}
}
teamsList := make([]string, len(teamsAndChannels))
teamsListIndex := 0
for teamName := range teamsAndChannels {
teamsList[teamsListIndex] = teamName
teamsListIndex++
}
slices.Sort(teamsList)
for _, teamName := range teamsList {
for i := range channelsPerTeam {
channelLine := createChannel(i, teamName)
teamsAndChannels[teamName] = append(teamsAndChannels[teamName], *channelLine.Channel.Name)
if err := encoder.Encode(channelLine); err != nil {
return fmt.Errorf("could not encode channel line: %w", err)
}
}
}
allUsers := make([]string, users+guests+deactivatedUsers)
allUsersIndex := 0
for i := range users {
userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, "")
if err := encoder.Encode(userLine); err != nil {
return fmt.Errorf("cannot encode user line: %w", err)
}
allUsers[allUsersIndex] = *userLine.User.Username
allUsersIndex++
}
for i := range guests {
userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, guestUser)
if err := encoder.Encode(userLine); err != nil {
return fmt.Errorf("cannot encode user line: %w", err)
}
allUsers[allUsersIndex] = *userLine.User.Username
allUsersIndex++
}
for i := range deactivatedUsers {
userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, deactivatedUser)
if err := encoder.Encode(userLine); err != nil {
return fmt.Errorf("cannot encode user line: %w", err)
}
allUsers[allUsersIndex] = *userLine.User.Username
allUsersIndex++
}
for team, channels := range teamsAndChannels {
for _, channel := range channels {
dates := sortedRandomDates(postsPerChannel)
for i := range postsPerChannel {
postLine := createPost(team, channel, allUsers, dates[i])
if err := encoder.Encode(postLine); err != nil {
return fmt.Errorf("cannot encode post line: %w", err)
}
}
}
}
for range directChannels {
user1 := allUsers[rand.Intn(len(allUsers))]
user2 := allUsers[rand.Intn(len(allUsers))]
channelLine := createDirectChannel([]string{user1, user2})
if err := encoder.Encode(channelLine); err != nil {
return fmt.Errorf("cannot encode channel line: %w", err)
}
}
for range directChannels {
user1 := allUsers[rand.Intn(len(allUsers))]
user2 := allUsers[rand.Intn(len(allUsers))]
dates := sortedRandomDates(postsPerDirectChannel)
for j := range postsPerDirectChannel {
postLine := createDirectPost([]string{user1, user2}, dates[j])
if err := encoder.Encode(postLine); err != nil {
return fmt.Errorf("cannot encode post line: %w", err)
}
}
}
for range groupChannels {
users := []string{}
totalUsers := 3 + rand.Intn(3)
for len(users) < totalUsers {
user := allUsers[rand.Intn(len(allUsers))]
if !slices.Contains(users, user) {
users = append(users, user)
}
}
channelLine := createDirectChannel(users)
if err := encoder.Encode(channelLine); err != nil {
return fmt.Errorf("cannot encode channel line: %w", err)
}
}
for range groupChannels {
users := []string{}
totalUsers := 3 + rand.Intn(3)
for len(users) < totalUsers {
user := allUsers[rand.Intn(len(allUsers))]
if !slices.Contains(users, user) {
users = append(users, user)
}
}
dates := sortedRandomDates(postsPerGroupChannel)
for j := range postsPerGroupChannel {
postLine := createDirectPost(users, dates[j])
if err := encoder.Encode(postLine); err != nil {
return fmt.Errorf("cannot encode post line: %w", err)
}
}
}
// if we're writing to stdout, we can finish here
if bulk == "-" {
return nil
}
if bulk == "" {
zipPath := filepath.Join(os.TempDir(), "mmctl-sampledata.zip")
defer os.Remove(zipPath)
if err := zipDir(zipPath, tmpDir); err != nil {
return fmt.Errorf("cannot compress %q directory into zipfile: %w", tmpDir, err)
}
isLocal, _ := command.Flags().GetBool("local")
if err := uploadAndProcess(c, zipPath, isLocal); err != nil {
return fmt.Errorf("cannot upload and process zipfile: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
func (s *MmctlUnitTestSuite) TestSampledataCmd() {
s.Run("should fail because you have more team memberships than teams", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("teams", 10, "")
cmd.Flags().Int("team-memberships", 11, "")
err := sampledataCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Contains(err.Error(), "more team memberships than teams")
})
s.Run("should fail because you have more channel memberships than channels per team", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("channels-per-team", 10, "")
cmd.Flags().Int("channel-memberships", 11, "")
err := sampledataCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Contains(err.Error(), "more channel memberships than channels per team")
})
s.Run("should fail because you have group channels and don't have enough users (6 users)", func() {
printer.Clean()
cmd := &cobra.Command{}
cmd.Flags().Int("group-channels", 1, "")
cmd.Flags().Int("users", 5, "")
err := sampledataCmdF(s.client, cmd, []string{})
s.Require().Error(err)
s.Require().Contains(err.Error(), "group channels generation with less than 6 users")
})
s.Run("should not fail with less than 6 users and no group channels", func() {
printer.Clean()
tmpFile, err := os.CreateTemp("", "mmctl-sampledata-test-")
s.Require().NoError(err)
tmpFile.Close()
defer os.Remove(tmpFile.Name())
cmd := &cobra.Command{}
cmd.Flags().String("bulk", tmpFile.Name(), "")
cmd.Flags().Int("group-channels", 0, "")
cmd.Flags().Int("users", 5, "")
err = sampledataCmdF(s.client, cmd, []string{})
s.Require().NoError(err)
})
}

View File

@@ -0,0 +1,460 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//nolint:gosec
package commands
import (
"fmt"
"math/rand"
"slices"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/app/imports"
"github.com/icrowley/fake"
)
func randomPastTime(seconds int) int64 {
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.FixedZone("UTC", 0))
return (today.Unix() * 1000) - int64(rand.Intn(seconds*1000))
}
func sortedRandomDates(size int) []int64 {
dates := make([]int64, size)
for i := range size {
dates[i] = randomPastTime(50000)
}
slices.Sort(dates)
return dates
}
func randomEmoji() string {
emojis := []string{"+1", "-1", "heart", "blush"}
return emojis[rand.Intn(len(emojis))]
}
func randomReaction(users []string, parentCreateAt int64) app.ReactionImportData {
user := users[rand.Intn(len(users))]
emoji := randomEmoji()
date := parentCreateAt + int64(rand.Intn(100000))
return app.ReactionImportData{
User: &user,
EmojiName: &emoji,
CreateAt: &date,
}
}
func randomReply(users []string, parentCreateAt int64) imports.ReplyImportData {
user := users[rand.Intn(len(users))]
message := randomMessage(users)
date := parentCreateAt + int64(rand.Intn(100000))
return imports.ReplyImportData{
User: &user,
Message: &message,
CreateAt: &date,
}
}
func randomMessage(users []string) string {
var message string
switch rand.Intn(30) {
case 0:
mention := users[rand.Intn(len(users))]
message = "@" + mention + " " + fake.Sentence()
case 1:
switch rand.Intn(2) {
case 0:
mattermostVideos := []string{"Q4MgnxbpZas", "BFo7E9-Kc_E", "LsMLR-BHsKg", "MRmGDhlMhNA", "mUOPxT7VgWc"}
message = "https://www.youtube.com/watch?v=" + mattermostVideos[rand.Intn(len(mattermostVideos))]
case 1:
mattermostTweets := []string{"943119062334353408", "949370809528832005", "948539688171819009", "939122439115681792", "938061722027425797"}
message = "https://twitter.com/mattermosthq/status/" + mattermostTweets[rand.Intn(len(mattermostTweets))]
}
case 2:
message = ""
if rand.Intn(2) == 0 {
message += fake.Sentence()
}
lenList := rand.Intn(4) + 1
for range lenList {
message += "\n * " + fake.Word()
}
default:
if rand.Intn(2) == 0 {
message = fake.Sentence()
} else {
message = fake.Paragraph()
}
if rand.Intn(3) == 0 {
message += "\n" + fake.Sentence()
}
if rand.Intn(3) == 0 {
message += "\n" + fake.Sentence()
}
if rand.Intn(3) == 0 {
message += "\n" + fake.Sentence()
}
}
return message
}
func createUser(idx int, teamMemberships int, channelMemberships int, teamsAndChannels map[string][]string, profileImages []string, userType string) imports.LineImportData {
firstName := fake.FirstName()
lastName := fake.LastName()
position := fake.JobTitle()
username := fmt.Sprintf("%s.%s", strings.ToLower(firstName), strings.ToLower(lastName))
roles := "system_user"
var password string
var email string
switch userType {
case guestUser:
password = fmt.Sprintf("SampleGu@st-%03d", idx)
email = fmt.Sprintf("guest-%d@sample.mattermost.com", idx)
roles = "system_guest"
if idx == 0 {
username = "guest"
email = "guest@sample.mattermost.com"
}
case deactivatedUser:
password = fmt.Sprintf("SampleDe@ctivated-%d", idx)
email = fmt.Sprintf("deactivated-%d@sample.mattermost.com", idx)
default:
password = fmt.Sprintf("SampleUs@r-%03d", idx)
email = fmt.Sprintf("user-%d@sample.mattermost.com", idx)
if idx == 0 {
username = "sysadmin"
password = "Sys@dmin-sample1"
email = "sysadmin@sample.mattermost.com"
} else if idx == 1 {
username = "user-1"
}
if idx%5 == 0 {
roles = "system_admin system_user"
}
}
// The 75% of the users have custom profile image
var profileImage *string
if rand.Intn(4) != 0 {
profileImageSelector := rand.Int()
if len(profileImages) > 0 {
profileImage = &profileImages[profileImageSelector%len(profileImages)]
}
}
useMilitaryTime := "false"
if idx != 0 && rand.Intn(2) == 0 {
useMilitaryTime = "true"
}
collapsePreviews := "false"
if idx != 0 && rand.Intn(2) == 0 {
collapsePreviews = "true"
}
messageDisplay := "clean"
if idx != 0 && rand.Intn(2) == 0 {
messageDisplay = "compact"
}
channelDisplayMode := "full"
if idx != 0 && rand.Intn(2) == 0 {
channelDisplayMode = "centered"
}
// Some users has nickname
nickname := ""
if rand.Intn(5) == 0 {
nickname = fake.Company()
}
// sysadmin, user-1 and user-2 users skip tutorial steps
// Other half of users also skip tutorial steps
tutorialStep := "999"
if idx > 2 {
switch rand.Intn(6) {
case 1:
tutorialStep = "1"
case 2:
tutorialStep = "2"
case 3:
tutorialStep = "3"
}
}
teams := []imports.UserTeamImportData{}
possibleTeams := []string{}
for teamName := range teamsAndChannels {
possibleTeams = append(possibleTeams, teamName)
}
slices.Sort(possibleTeams)
for range teamMemberships {
if len(possibleTeams) == 0 {
break
}
position := rand.Intn(len(possibleTeams))
team := possibleTeams[position]
possibleTeams = append(possibleTeams[:position], possibleTeams[position+1:]...)
if teamChannels, err := teamsAndChannels[team]; err {
teams = append(teams, createTeamMembership(channelMemberships, teamChannels, &team, userType == guestUser))
}
}
var deleteAt int64
if userType == deactivatedUser {
deleteAt = model.GetMillis()
}
user := imports.UserImportData{
Avatar: imports.Avatar{
ProfileImage: profileImage,
},
Username: &username,
Email: &email,
Password: &password,
Nickname: &nickname,
FirstName: &firstName,
LastName: &lastName,
Position: &position,
Roles: &roles,
Teams: &teams,
UseMilitaryTime: &useMilitaryTime,
CollapsePreviews: &collapsePreviews,
MessageDisplay: &messageDisplay,
ChannelDisplayMode: &channelDisplayMode,
TutorialStep: &tutorialStep,
DeleteAt: &deleteAt,
}
return imports.LineImportData{
Type: "user",
User: &user,
}
}
func createTeamMembership(numOfchannels int, teamChannels []string, teamName *string, guest bool) imports.UserTeamImportData {
roles := "team_user"
if guest {
roles = "team_guest"
} else if rand.Intn(5) == 0 {
roles = "team_user team_admin"
}
channels := []imports.UserChannelImportData{}
teamChannelsCopy := append([]string(nil), teamChannels...)
for range numOfchannels {
if len(teamChannelsCopy) == 0 {
break
}
position := rand.Intn(len(teamChannelsCopy))
channelName := teamChannelsCopy[position]
teamChannelsCopy = append(teamChannelsCopy[:position], teamChannelsCopy[position+1:]...)
channels = append(channels, createChannelMembership(channelName, guest))
}
return imports.UserTeamImportData{
Name: teamName,
Roles: &roles,
Channels: &channels,
}
}
func createChannelMembership(channelName string, guest bool) imports.UserChannelImportData {
roles := "channel_user"
if guest {
roles = "channel_guest"
} else if rand.Intn(5) == 0 {
roles = "channel_user channel_admin"
}
favorite := rand.Intn(5) == 0
return imports.UserChannelImportData{
Name: &channelName,
Roles: &roles,
Favorite: &favorite,
}
}
func getSampleTeamName(idx int) string {
for {
name := fmt.Sprintf("%s-%d", fake.Word(), idx)
if !model.IsReservedTeamName(name) {
return name
}
}
}
func createTeam(idx int) imports.LineImportData {
displayName := fake.Word()
name := getSampleTeamName(idx)
allowOpenInvite := rand.Intn(2) == 0
description := fake.Paragraph()
if len(description) > 255 {
description = description[0:255]
}
teamType := "O"
if rand.Intn(2) == 0 {
teamType = "I"
}
team := imports.TeamImportData{
DisplayName: &displayName,
Name: &name,
AllowOpenInvite: &allowOpenInvite,
Description: &description,
Type: &teamType,
}
return imports.LineImportData{
Type: "team",
Team: &team,
}
}
func createChannel(idx int, teamName string) imports.LineImportData {
displayName := fake.Word()
name := fmt.Sprintf("%s-%d", fake.Word(), idx)
header := fake.Paragraph()
purpose := fake.Paragraph()
if len(purpose) > 250 {
purpose = purpose[0:250]
}
channelType := model.ChannelTypePrivate
if rand.Intn(2) == 0 {
channelType = model.ChannelTypeOpen
}
channel := imports.ChannelImportData{
Team: &teamName,
Name: &name,
DisplayName: &displayName,
Type: &channelType,
Header: &header,
Purpose: &purpose,
}
return imports.LineImportData{
Type: "channel",
Channel: &channel,
}
}
func createPost(team string, channel string, allUsers []string, createAt int64) imports.LineImportData {
message := randomMessage(allUsers)
user := allUsers[rand.Intn(len(allUsers))]
// Some messages are flagged by a user
flaggedBy := []string{}
if rand.Intn(10) == 0 {
flaggedBy = append(flaggedBy, allUsers[rand.Intn(len(allUsers))])
}
reactions := []app.ReactionImportData{}
if rand.Intn(10) == 0 {
for {
reactions = append(reactions, randomReaction(allUsers, createAt))
if rand.Intn(3) == 0 {
break
}
}
}
replies := []imports.ReplyImportData{}
if rand.Intn(10) == 0 {
for {
replies = append(replies, randomReply(allUsers, createAt))
if rand.Intn(4) == 0 {
break
}
}
}
post := imports.PostImportData{
Team: &team,
Channel: &channel,
User: &user,
Message: &message,
CreateAt: &createAt,
FlaggedBy: &flaggedBy,
Reactions: &reactions,
Replies: &replies,
}
return imports.LineImportData{
Type: "post",
Post: &post,
}
}
func createDirectChannel(members []string) imports.LineImportData {
header := fake.Sentence()
var p []*imports.DirectChannelMemberImportData
for _, m := range members {
p = append(p, &imports.DirectChannelMemberImportData{
Username: new(m),
})
}
channel := imports.DirectChannelImportData{
Participants: p,
Header: &header,
}
return imports.LineImportData{
Type: "direct_channel",
DirectChannel: &channel,
}
}
func createDirectPost(members []string, createAt int64) imports.LineImportData {
message := randomMessage(members)
user := members[rand.Intn(len(members))]
// Some messages are flagged by an user
flaggedBy := []string{}
if rand.Intn(10) == 0 {
flaggedBy = append(flaggedBy, members[rand.Intn(len(members))])
}
reactions := []app.ReactionImportData{}
if rand.Intn(10) == 0 {
for {
reactions = append(reactions, randomReaction(members, createAt))
if rand.Intn(3) == 0 {
break
}
}
}
replies := []imports.ReplyImportData{}
if rand.Intn(10) == 0 {
for {
replies = append(replies, randomReply(members, createAt))
if rand.Intn(4) == 0 {
break
}
}
}
post := imports.DirectPostImportData{
ChannelMembers: &members,
User: &user,
Message: &message,
CreateAt: &createAt,
FlaggedBy: &flaggedBy,
Reactions: &reactions,
Replies: &replies,
}
return imports.LineImportData{
Type: "direct_post",
DirectPost: &post,
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
// Verifies all sampledata passwords meet the FIPS minimum length (PasswordFIPSMinimumLength).
func TestCreateUserPasswordLength(t *testing.T) {
const minLen = model.PasswordFIPSMinimumLength
indices := []int{0, 1, 9, 10, 99, 100, 999, 1000}
userTypes := []string{guestUser, deactivatedUser, ""}
for _, userType := range userTypes {
for _, idx := range indices {
name := userType
if name == "" {
name = "user"
}
t.Run(fmt.Sprintf("%s/idx=%d", name, idx), func(t *testing.T) {
t.Parallel()
data := createUser(idx, 0, 0, map[string][]string{}, nil, userType)
pwd := *data.User.Password
require.GreaterOrEqualf(t, len(pwd), minLen,
"password %q (userType=%q idx=%d) is shorter than FIPS minimum %d",
pwd, userType, idx, minLen)
})
}
}
}

View File

@@ -0,0 +1,257 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"io"
"os"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
var SystemCmd = &cobra.Command{
Use: "system",
Short: "System management",
Long: `System management commands for interacting with the server state and configuration.`,
}
var SystemGetBusyCmd = &cobra.Command{
Use: "getbusy",
Short: "Get the current busy state",
Long: `Gets the server busy state (high load) and timestamp corresponding to when the server busy flag will be automatically cleared.`,
Example: ` system getbusy`,
Args: cobra.NoArgs,
RunE: withClient(getBusyCmdF),
}
var SystemSetBusyCmd = &cobra.Command{
Use: "setbusy -s [seconds]",
Short: "Set the busy state to true",
Long: `Set the busy state to true for the specified number of seconds, which disables non-critical services.`,
Example: ` system setbusy -s 3600`,
Args: cobra.NoArgs,
RunE: withClient(setBusyCmdF),
}
var SystemClearBusyCmd = &cobra.Command{
Use: "clearbusy",
Short: "Clears the busy state",
Long: `Clear the busy state, which re-enables non-critical services.`,
Example: ` system clearbusy`,
Args: cobra.NoArgs,
RunE: withClient(clearBusyCmdF),
}
var SystemVersionCmd = &cobra.Command{
Use: "version",
Short: "Prints the remote server version",
Long: "Prints the server version of the currently connected Mattermost instance",
Example: ` system version`,
Args: cobra.NoArgs,
RunE: withClient(systemVersionCmdF),
}
var SystemStatusCmd = &cobra.Command{
Use: "status",
Short: "Prints the status of the server",
Long: "Prints the server status calculated using several basic server healthchecks",
Example: ` system status`,
Args: cobra.NoArgs,
RunE: withClient(systemStatusCmdF),
}
var SystemSupportPacketCmd = &cobra.Command{
Use: "supportpacket",
Short: "Download a Support Packet",
Long: "Generate and download a Support Packet of the server to share it with Mattermost Support",
Example: ` system supportpacket`,
Args: cobra.NoArgs,
RunE: withClient(systemSupportPacketCmdF),
}
var SystemNukeCmd = &cobra.Command{
Use: "nuke",
Short: "Destructive operations that permanently delete data",
Long: "Destructive operations that permanently and irreversibly delete server data. Use with extreme caution.",
}
var SystemNukeUsersCmd = &cobra.Command{
Use: "users",
Short: "Delete all users and all posts. Local command only.",
Long: "Permanently delete all users and all related information including posts. This command can only be run in local mode.",
Example: " system nuke users",
Args: cobra.NoArgs,
PreRun: localOnlyPrecheck,
RunE: withClient(nukeUsersCmdF),
}
func init() {
SystemSetBusyCmd.Flags().UintP("seconds", "s", 3600, "Number of seconds until server is automatically marked as not busy.")
_ = SystemSetBusyCmd.MarkFlagRequired("seconds")
SystemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Define the output file name")
SystemNukeUsersCmd.Flags().Bool("confirm", false, "Confirm you really want to permanently delete all users, posts, and related data, and that a DB backup has been performed")
SystemNukeCmd.AddCommand(
SystemNukeUsersCmd,
)
SystemCmd.AddCommand(
SystemGetBusyCmd,
SystemSetBusyCmd,
SystemClearBusyCmd,
SystemVersionCmd,
SystemStatusCmd,
SystemSupportPacketCmd,
SystemNukeCmd,
)
RootCmd.AddCommand(SystemCmd)
}
func getBusyCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
printer.SetSingle(true)
sbs, _, err := c.GetServerBusy(context.TODO())
if err != nil {
return fmt.Errorf("unable to get busy state: %w", err)
}
printer.PrintT("busy:{{.Busy}} expires:{{.Expires_ts}}", sbs)
return nil
}
func setBusyCmdF(c client.Client, cmd *cobra.Command, args []string) error {
printer.SetSingle(true)
seconds, err := cmd.Flags().GetUint("seconds")
if err != nil || seconds == 0 {
return errors.New("seconds must be a number > 0")
}
_, err = c.SetServerBusy(context.TODO(), int(seconds))
if err != nil {
return fmt.Errorf("unable to set busy state: %w", err)
}
printer.PrintT("Busy state set", map[string]string{"status": "ok"})
return nil
}
func clearBusyCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
printer.SetSingle(true)
_, err := c.ClearServerBusy(context.TODO())
if err != nil {
return fmt.Errorf("unable to clear busy state: %w", err)
}
printer.PrintT("Busy state cleared", map[string]string{"status": "ok"})
return nil
}
func systemVersionCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
printer.SetSingle(true)
// server version information comes with all responses. We can't
// use the initial "withClient" connection information as local
// mode doesn't need to log in, so we use an endpoint that will
// always return a valid response
_, resp, err := c.GetPing(context.TODO())
if err != nil {
return fmt.Errorf("unable to fetch server version: %w", err)
}
printer.PrintT("Server version {{.version}}", map[string]string{"version": resp.ServerVersion})
return nil
}
func systemStatusCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
printer.SetSingle(true)
status, _, err := c.GetPingWithOptions(context.TODO(), model.SystemPingOptions{
FullStatus: true,
RESTSemantics: true,
})
if err != nil {
return fmt.Errorf("unable to fetch server status: %w", err)
}
printer.PrintT(`Server status: {{.status}}
Android Latest Version: {{.AndroidLatestVersion}}
Android Minimum Version: {{.AndroidMinVersion}}
Desktop Latest Version: {{.DesktopLatestVersion}}
Desktop Minimum Version: {{.DesktopMinVersion}}
Ios Latest Version: {{.IosLatestVersion}}
Ios Minimum Version: {{.IosMinVersion}}
Database Status: {{.database_status}}
Filestore Status: {{.filestore_status}}`, status)
// Check health status and return non-zero exit code if any component is unhealthy
if status["status"] != model.StatusOk {
return fmt.Errorf("server status is unhealthy: %s", status["status"])
}
if dbStatus, ok := status["database_status"]; ok && dbStatus != model.StatusOk {
return fmt.Errorf("database status is unhealthy: %s", dbStatus)
}
if filestoreStatus, ok := status["filestore_status"]; ok && filestoreStatus != model.StatusOk {
return fmt.Errorf("filestore status is unhealthy: %s", filestoreStatus)
}
return nil
}
func systemSupportPacketCmdF(c client.Client, cmd *cobra.Command, _ []string) error {
printer.SetSingle(true)
filename, err := cmd.Flags().GetString("output-file")
if err != nil {
return err
}
printer.Print("Downloading Support Packet")
data, rFilename, _, err := c.GenerateSupportPacket(context.TODO())
if err != nil {
return fmt.Errorf("unable to fetch Support Packet: %w", err)
}
if filename == "" {
filename = rFilename
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create zip file: %w", err)
}
_, err = io.Copy(file, data)
if err != nil {
return fmt.Errorf("failed to write to zip file: %w", err)
}
printer.PrintT("Downloaded Support Packet to {{ .filename }}", map[string]string{"filename": filename})
return nil
}
func nukeUsersCmdF(c client.Client, cmd *cobra.Command, args []string) error {
confirmFlag, _ := cmd.Flags().GetBool("confirm")
if !confirmFlag {
if err := getConfirmation("Are you sure you want to permanently delete all users, posts, and related data?", true); err != nil {
return err
}
}
if _, err := c.PermanentDeleteAllUsers(context.TODO()); err != nil {
return err
}
defer printer.Print("All users successfully deleted")
return nil
}

View File

@@ -0,0 +1,250 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"strings"
"time"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
)
func (s *MmctlE2ETestSuite) TestGetBusyCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.th.App.Srv().Platform().Busy.Set(time.Minute)
defer s.th.App.Srv().Platform().Busy.Clear()
s.Run("MM-T3979 Should fail when regular user attempts to get server busy status", func() {
printer.Clean()
err := getBusyCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3956 Get server busy status", func(c client.Client) {
printer.Clean()
err := getBusyCmdF(c, &cobra.Command{}, nil)
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 1)
state, ok := printer.GetLines()[0].(*model.ServerBusyState)
s.Require().True(ok, true)
s.Require().True(state.Busy, true)
s.Require().Len(printer.GetErrorLines(), 0)
})
}
func (s *MmctlE2ETestSuite) TestNukeUsersCmd() {
s.SetupTestHelper().InitBasic(s.T())
s.Run("Delete all users as unprivileged user should not work", func() {
printer.Clean()
cmd := &cobra.Command{}
confirm := true
cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm")
err := nukeUsersCmdF(s.th.Client, cmd, []string{})
s.Require().NotNil(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
// expect users not deleted
users, err := s.th.App.GetUsersPage(&model.UserGetOptions{
Page: 0,
PerPage: 10,
}, true)
s.Require().Nil(err)
s.Require().NotZero(len(users))
})
s.Run("Delete all users as system admin through the port API should not work", func() {
printer.Clean()
cmd := &cobra.Command{}
confirm := true
cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm")
err := nukeUsersCmdF(s.th.SystemAdminClient, cmd, []string{})
s.Require().NotNil(err)
s.Len(printer.GetLines(), 0)
s.Len(printer.GetErrorLines(), 0)
// expect users not deleted
users, err := s.th.App.GetUsersPage(&model.UserGetOptions{
Page: 0,
PerPage: 10,
}, true)
s.Require().Nil(err)
s.Require().NotZero(len(users))
})
s.Run("Delete all users through local mode should work correctly", func() {
printer.Clean()
// populate with some user
for range 10 {
userData := model.User{
Username: "fakeuser" + model.NewRandomString(10),
Password: model.NewTestPassword(),
Email: s.th.GenerateTestEmail(),
}
_, err := s.th.App.CreateUser(s.th.Context, &userData)
s.Require().Nil(err)
}
cmd := &cobra.Command{}
confirm := true
cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm")
// delete all users only works on local mode
err := nukeUsersCmdF(s.th.LocalClient, cmd, []string{})
s.Require().Nil(err)
s.Len(printer.GetLines(), 1)
s.Len(printer.GetErrorLines(), 0)
s.Require().Equal(printer.GetLines()[0], "All users successfully deleted")
// expect users deleted
users, err := s.th.App.GetUsersPage(&model.UserGetOptions{
Page: 0,
PerPage: 10,
}, true)
s.Require().Nil(err)
s.Require().Zero(len(users))
})
}
func (s *MmctlE2ETestSuite) TestSetBusyCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.th.App.Srv().Platform().Busy.Clear()
cmd := &cobra.Command{}
cmd.Flags().Uint("seconds", 60, "")
s.Run("MM-T3980 Should fail when regular user attempts to set server busy status", func() {
printer.Clean()
err := setBusyCmdF(s.th.Client, cmd, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3957 Set server status to busy", func(c client.Client) {
printer.Clean()
err := setBusyCmdF(c, cmd, nil)
s.Require().NoError(err)
defer func() {
s.th.App.Srv().Platform().Busy.Clear()
s.Require().False(s.th.App.Srv().Platform().Busy.IsBusy())
}()
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], map[string]string{"status": "ok"})
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().True(s.th.App.Srv().Platform().Busy.IsBusy())
})
}
func (s *MmctlE2ETestSuite) TestClearBusyCmd() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
s.th.App.Srv().Platform().Busy.Set(time.Minute)
defer s.th.App.Srv().Platform().Busy.Clear()
s.Run("MM-T3981 Should fail when regular user attempts to clear server busy status", func() {
printer.Clean()
err := clearBusyCmdF(s.th.Client, &cobra.Command{}, nil)
s.Require().Error(err)
s.Require().Len(printer.GetLines(), 0)
s.Require().Len(printer.GetErrorLines(), 0)
})
s.RunForSystemAdminAndLocal("MM-T3958 Clear server status to busy", func(c client.Client) {
printer.Clean()
err := clearBusyCmdF(c, &cobra.Command{}, nil)
s.Require().NoError(err)
defer func() {
s.th.App.Srv().Platform().Busy.Set(time.Minute)
s.Require().True(s.th.App.Srv().Platform().Busy.IsBusy())
}()
s.Require().Len(printer.GetLines(), 1)
s.Require().Equal(printer.GetLines()[0], map[string]string{"status": "ok"})
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().False(s.th.App.Srv().Platform().Busy.IsBusy())
})
}
func (s *MmctlE2ETestSuite) TestSupportPacketCmdF() {
s.SetupEnterpriseTestHelper().InitBasic(s.T())
printer.SetFormat(printer.FormatPlain)
s.T().Cleanup(func() { printer.SetFormat(printer.FormatJSON) })
s.Run("Download Support Packet with default filename", func() {
printer.Clean()
err := systemSupportPacketCmdF(s.th.SystemAdminClient, SystemSupportPacketCmd, []string{})
s.Require().NoError(err)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(printer.GetLines()[0], "Downloading Support Packet")
s.Require().Contains(printer.GetLines()[1], "Downloaded Support Packet to ")
s.Require().Len(printer.GetErrorLines(), 0)
var found bool
entries, err := os.ReadDir(".")
s.Require().NoError(err)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "mm_support_packet_") && strings.HasSuffix(e.Name(), ".zip") {
b, err := os.ReadFile(e.Name())
s.NoError(err)
s.NotEmpty(b, b)
s.T().Cleanup(func() {
err = os.Remove(e.Name())
s.Require().NoError(err)
})
found = true
}
}
s.True(found)
})
s.Run("Download Support Packet with custom filename", func() {
printer.Clean()
systemSupportPacketCmd := &cobra.Command{}
systemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Define the output file name")
err := systemSupportPacketCmd.ParseFlags([]string{"-o", "foo.zip"})
s.Require().NoError(err)
defer func() {
s.Require().NoError(os.Remove("foo.zip"))
}()
err = systemSupportPacketCmdF(s.th.SystemAdminClient, systemSupportPacketCmd, []string{})
s.Require().NoError(err)
s.Require().Len(printer.GetErrorLines(), 0)
s.Require().Len(printer.GetLines(), 2)
s.Require().Equal(printer.GetLines()[0], "Downloading Support Packet")
s.Require().Equal(printer.GetLines()[1], "Downloaded Support Packet to foo.zip")
b, err := os.ReadFile("foo.zip")
s.Require().NoError(err)
s.NotNil(b, b)
})
}

Some files were not shown because too many files have changed in this diff Show More