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,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var coverprofileCounters = make(map[string]int)
var mainHelper *testlib.MainHelper
type testHelper struct {
*api4.TestHelper
config *model.Config
tempDir string
configFilePath string
disableAutoConfig bool
}
// Setup creates an instance of testHelper.
func SetupWithStoreMock(tb testing.TB) *testHelper {
dir, err := testlib.SetupTestResources()
require.NoError(tb, err)
tb.Cleanup(func() {
err = os.RemoveAll(dir)
require.NoError(tb, err)
})
api4TestHelper := api4.SetupWithStoreMock(tb)
systemStore := mocks.SystemStore{}
systemStore.On("Get").Return(make(model.StringMap), nil)
licenseStore := mocks.LicenseStore{}
licenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
api4TestHelper.App.Srv().Store().(*mocks.Store).On("System").Return(&systemStore)
api4TestHelper.App.Srv().Store().(*mocks.Store).On("License").Return(&licenseStore)
testHelper := &testHelper{
TestHelper: api4TestHelper,
tempDir: dir,
configFilePath: filepath.Join(dir, "config-helper.json"),
}
config := &model.Config{}
config.SetDefaults()
testHelper.SetConfig(config)
return testHelper
}
// InitBasic simply proxies to api4.InitBasic, while still returning a testHelper.
func (h *testHelper) InitBasic(tb testing.TB) *testHelper {
h.TestHelper.InitBasic(tb)
return h
}
// TemporaryDirectory returns the temporary directory created for user by the test helper.
func (h *testHelper) TemporaryDirectory() string {
return h.tempDir
}
// Config returns the configuration passed to a running command.
func (h *testHelper) Config() *model.Config {
return h.config.Clone()
}
// ConfigPath returns the path to the temporary config file passed to a running command.
func (h *testHelper) ConfigPath() string {
return h.configFilePath
}
// SetConfig replaces the configuration passed to a running command.
func (h *testHelper) SetConfig(config *model.Config) {
if !testing.Short() {
config.SqlSettings = *mainHelper.GetSQLSettings()
}
h.config = config
buf, err := json.Marshal(config)
if err != nil {
panic("failed to marshal config: " + err.Error())
}
if err := os.WriteFile(h.configFilePath, buf, 0600); err != nil {
panic("failed to write file " + h.configFilePath + ": " + err.Error())
}
}
// SetAutoConfig configures whether the --config flag is automatically passed to a running command.
func (h *testHelper) SetAutoConfig(autoConfig bool) {
h.disableAutoConfig = !autoConfig
}
func (h *testHelper) execArgs(t *testing.T, args []string) []string {
ret := []string{"-test.v", "-test.run", "ExecCommand"}
if coverprofile := flag.Lookup("test.coverprofile").Value.String(); coverprofile != "" {
dir := filepath.Dir(coverprofile)
base := filepath.Base(coverprofile)
baseParts := strings.SplitN(base, ".", 2)
name := strings.Replace(t.Name(), "/", "_", -1)
coverprofileCounters[name] = coverprofileCounters[name] + 1
baseParts[0] = fmt.Sprintf("%v-%v-%v", baseParts[0], name, coverprofileCounters[name])
ret = append(ret, "-test.coverprofile", filepath.Join(dir, strings.Join(baseParts, ".")))
}
ret = append(ret, "--")
// Unless the test passes a `--config` of its own, create a temporary one from the default
// configuration with the current test database applied.
hasConfig := h.disableAutoConfig
if slices.Contains(args, "--config") {
hasConfig = true
}
if !hasConfig {
ret = append(ret, "--config", h.configFilePath)
}
ret = append(ret, args...)
return ret
}
func (h *testHelper) cmd(t *testing.T, args []string) *exec.Cmd {
path, err := os.Executable()
require.NoError(t, err)
cmd := exec.Command(path, h.execArgs(t, args)...)
cmd.Env = []string{}
for _, env := range os.Environ() {
// Ignore MM_SQLSETTINGS_DATASOURCE from the environment, since we override.
if strings.HasPrefix(env, "MM_SQLSETTINGS_DATASOURCE=") {
continue
}
cmd.Env = append(cmd.Env, env)
}
return cmd
}
// CheckCommand invokes the test binary, returning the output modified for assertion testing.
func (h *testHelper) CheckCommand(t *testing.T, args ...string) string {
output, err := h.cmd(t, args).CombinedOutput()
require.NoError(t, err, string(output))
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(string(output)), "PASS"))
}
// RunCommand invokes the test binary, returning only any error.
func (h *testHelper) RunCommand(t *testing.T, args ...string) error {
return h.cmd(t, args).Run()
}
// RunCommandWithOutput is a variant of RunCommand that returns the unmodified output and any error.
func (h *testHelper) RunCommandWithOutput(t *testing.T, args ...string) (string, error) {
cmd := h.cmd(t, args)
var buf bytes.Buffer
reader, writer := io.Pipe()
cmd.Stdout = writer
cmd.Stderr = writer
done := make(chan bool)
go func() {
io.Copy(&buf, reader)
close(done)
}()
err := cmd.Run()
writer.Close()
<-done
return buf.String(), err
}

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/morph"
"github.com/mattermost/morph/models"
)
var DbCmd = &cobra.Command{
Use: "db",
Short: "Commands related to the database",
}
var InitDbCmd = &cobra.Command{
Use: "init",
Short: "Initialize the database",
Long: `Initialize the database for a given DSN, executing the migrations and loading the custom defaults if any.
This command should be run using a database configuration DSN.`,
Example: ` # you can use the config flag to pass the DSN
$ mattermost db init --config postgres://localhost/mattermost
# or you can use the MM_CONFIG environment variable
$ MM_CONFIG=postgres://localhost/mattermost mattermost db init
# and you can set a custom defaults file to be loaded into the database
$ MM_CUSTOM_DEFAULTS_PATH=custom.json MM_CONFIG=postgres://localhost/mattermost mattermost db init`,
Args: cobra.NoArgs,
RunE: initDbCmdF,
}
var ResetCmd = &cobra.Command{
Use: "reset",
Short: "Reset the database to initial state",
Long: "Completely erases the database causing the loss of all data. This will reset Mattermost to its initial state.",
RunE: resetCmdF,
}
var MigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Migrate the database if there are any unapplied migrations",
Long: "Run the missing migrations from the migrations table.",
RunE: migrateCmdF,
}
var DowngradeCmd = &cobra.Command{
Use: "downgrade",
Short: "Downgrade the database with the given plan or migration numbers",
Long: "Downgrade the database with the given plan or migration numbers. " +
"The plan will be read from filestore hence the path should be relative to file store root.",
RunE: downgradeCmdF,
Args: cobra.ExactArgs(1),
}
var DBVersionCmd = &cobra.Command{
Use: "version",
Short: "Returns the recent applied version number",
RunE: dbVersionCmdF,
}
func init() {
ResetCmd.Flags().Bool("confirm", false, "Confirm you really want to delete everything and a DB backup has been performed.")
DBVersionCmd.Flags().Bool("all", false, "Returns all applied migrations")
MigrateCmd.Flags().Bool("auto-recover", false, "Recover the database to it's existing state after a failed migration.")
MigrateCmd.Flags().Bool("save-plan", false, "Saves the migration plan into file store so that it can be used in the future.")
MigrateCmd.Flags().Bool("dry-run", false, "Runs the migration plan without applying it.")
DowngradeCmd.Flags().Bool("auto-recover", false, "Recover the database to it's existing state after a failed migration.")
DowngradeCmd.Flags().Bool("dry-run", false, "Runs the migration plan without applying it.")
DbCmd.AddCommand(
InitDbCmd,
ResetCmd,
MigrateCmd,
DowngradeCmd,
DBVersionCmd,
)
RootCmd.AddCommand(
DbCmd,
)
}
func initDbCmdF(command *cobra.Command, _ []string) error {
logger := mlog.CreateConsoleLogger()
dsn := getConfigDSN(command, config.GetEnvironment())
if !config.IsDatabaseDSN(dsn) {
return errors.New("this command should be run using a database configuration DSN")
}
customDefaults, err := loadCustomDefaults()
if err != nil {
return errors.Wrap(err, "error loading custom configuration defaults")
}
configStore, err := config.NewStoreFromDSN(getConfigDSN(command, config.GetEnvironment()), false, customDefaults, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
defer configStore.Close()
sqlStore, err := sqlstore.New(configStore.Get().SqlSettings, logger, nil)
if err != nil {
return errors.Wrap(err, "failed to initialize store")
}
defer sqlStore.Close()
CommandPrettyPrintln("Database store correctly initialised")
return nil
}
func resetCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
ss, err := initStoreCommandContextCobra(logger, command)
if err != nil {
return errors.Wrap(err, "could not initialize store")
}
defer ss.Close()
confirmFlag, _ := command.Flags().GetBool("confirm")
if !confirmFlag {
var confirm string
CommandPrettyPrintln("Have you performed a database backup? (YES/NO): ")
fmt.Scanln(&confirm)
if confirm != "YES" {
return errors.New("ABORTED: You did not answer YES exactly, in all capitals.")
}
CommandPrettyPrintln("Are you sure you want to delete everything? All data will be permanently deleted? (YES/NO): ")
fmt.Scanln(&confirm)
if confirm != "YES" {
return errors.New("ABORTED: You did not answer YES exactly, in all capitals.")
}
}
ss.DropAllTables()
CommandPrettyPrintln("Database successfully reset")
return nil
}
func migrateCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
cfgDSN := getConfigDSN(command, config.GetEnvironment())
recoverFlag, _ := command.Flags().GetBool("auto-recover")
savePlan, _ := command.Flags().GetBool("save-plan")
dryRun, _ := command.Flags().GetBool("dry-run")
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
config := cfgStore.Get()
migrator, err := sqlstore.NewMigrator(config.SqlSettings, logger, dryRun)
if err != nil {
return errors.Wrap(err, "failed to create migrator")
}
defer migrator.Close()
plan, err := migrator.GeneratePlan(recoverFlag)
if err != nil {
return errors.Wrap(err, "failed to generate migration plan")
}
if len(plan.Migrations) == 0 {
CommandPrettyPrintln("No migrations to apply.")
return nil
}
if savePlan || recoverFlag {
backend, err2 := filestore.NewFileBackend(ConfigToFileBackendSettings(&config.FileSettings, false, true))
if err2 != nil {
return fmt.Errorf("failed to initialize filebackend: %w", err2)
}
b, mErr := json.MarshalIndent(plan, "", " ")
if mErr != nil {
return fmt.Errorf("failed to marshal plan: %w", mErr)
}
fileName, err2 := migrator.GetFileName(plan)
if err2 != nil {
return fmt.Errorf("failed to generate plan file: %w", err2)
}
_, err = backend.WriteFile(bytes.NewReader(b), fileName+".json")
if err != nil {
return fmt.Errorf("failed to write migration plan: %w", err)
}
CommandPrettyPrintln(
fmt.Sprintf("%s\nThe migration plan has been saved. File: %q.\nNote that "+
" migration plan is saved into file store, so the filepath will be relative to root of file store\n%s",
strings.Repeat("*", 80), fileName+".json", strings.Repeat("*", 80)))
}
if !dryRun {
if preMigrationError := migrator.PreMigrate(); preMigrationError != nil {
return errors.Wrap(preMigrationError, "failed to run pre-migrations")
}
}
err = migrator.MigrateWithPlan(plan, dryRun)
if err != nil {
return errors.Wrap(err, "failed to migrate with the plan")
}
CommandPrettyPrintln("Database successfully migrated")
return nil
}
func downgradeCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
cfgDSN := getConfigDSN(command, config.GetEnvironment())
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
config := cfgStore.Get()
dryRun, _ := command.Flags().GetBool("dry-run")
recoverFlag, _ := command.Flags().GetBool("auto-recover")
backend, err2 := filestore.NewFileBackend(ConfigToFileBackendSettings(&config.FileSettings, false, true))
if err2 != nil {
return fmt.Errorf("failed to initialize filebackend: %w", err2)
}
migrator, err := sqlstore.NewMigrator(config.SqlSettings, logger, dryRun)
if err != nil {
return errors.Wrap(err, "failed to create migrator")
}
defer migrator.Close()
// check if the input is version numbers or a file
// if the input is given as a file, we assume it's a migration plan
versions := strings.Split(args[0], ",")
if _, sErr := strconv.Atoi(versions[0]); sErr == nil {
CommandPrettyPrintln("Database will be downgraded with the following versions: ", versions)
err = migrator.DowngradeMigrations(dryRun, versions...)
if err != nil {
return errors.Wrap(err, "failed to downgrade migrations")
}
CommandPrettyPrintln("Database successfully downgraded")
return nil
}
b, err := backend.ReadFile(args[0])
if err != nil {
return fmt.Errorf("failed to read plan: %w", err)
}
var plan models.Plan
err = json.Unmarshal(b, &plan)
if err != nil {
return fmt.Errorf("failed to unmarshal plan: %w", err)
}
morph.SwapPlanDirection(&plan)
plan.Auto = recoverFlag
err = migrator.MigrateWithPlan(&plan, dryRun)
if err != nil {
return errors.Wrap(err, "failed to migrate with the plan")
}
CommandPrettyPrintln("Database successfully downgraded")
return nil
}
func dbVersionCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
ss, err := initStoreCommandContextCobra(logger, command)
if err != nil {
return errors.Wrap(err, "could not initialize store")
}
defer ss.Close()
allFlag, _ := command.Flags().GetBool("all")
if allFlag {
applied, err2 := ss.GetAppliedMigrations()
if err2 != nil {
return errors.Wrap(err2, "failed to get applied migrations")
}
for _, migration := range applied {
CommandPrettyPrintln(fmt.Sprintf("Varsion: %d, Name: %s", migration.Version, migration.Name))
}
return nil
}
v, err := ss.GetDBSchemaVersion()
if err != nil {
return errors.Wrap(err, "failed to get schema version")
}
CommandPrettyPrintln("Current database schema version is: " + strconv.Itoa(v))
return nil
}
func ConfigToFileBackendSettings(s *model.FileSettings, enableComplianceFeature bool, skipVerify bool) filestore.FileBackendSettings {
if *s.DriverName == model.ImageDriverLocal {
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
Directory: *s.Directory,
}
}
if *s.DriverName == model.ImageDriverAzure {
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AzureStorageAccount: *s.AzureStorageAccount,
AzureAuthMode: *s.AzureAuthMode,
AzureAccessKey: *s.AzureAccessKey,
AzureContainer: *s.AzureContainer,
AzurePathPrefix: *s.AzurePathPrefix,
AzureCloud: *s.AzureCloud,
AzureEndpoint: *s.AzureEndpoint,
AzureSSL: s.AzureSSL == nil || *s.AzureSSL,
AzureRequestTimeoutMilliseconds: *s.AzureRequestTimeoutMilliseconds,
SkipVerify: skipVerify,
}
}
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AmazonS3AccessKeyId: *s.AmazonS3AccessKeyId,
AmazonS3SecretAccessKey: *s.AmazonS3SecretAccessKey,
AmazonS3Bucket: *s.AmazonS3Bucket,
AmazonS3PathPrefix: *s.AmazonS3PathPrefix,
AmazonS3Region: *s.AmazonS3Region,
AmazonS3Endpoint: *s.AmazonS3Endpoint,
AmazonS3SSL: s.AmazonS3SSL == nil || *s.AmazonS3SSL,
AmazonS3SignV2: s.AmazonS3SignV2 != nil && *s.AmazonS3SignV2,
AmazonS3SSE: s.AmazonS3SSE != nil && *s.AmazonS3SSE && enableComplianceFeature,
AmazonS3Trace: s.AmazonS3Trace != nil && *s.AmazonS3Trace,
AmazonS3RequestTimeoutMilliseconds: *s.AmazonS3RequestTimeoutMilliseconds,
SkipVerify: skipVerify,
}
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
dbsql "database/sql"
stdErrors "errors"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/config"
)
const (
dbPingDefaultTimeout = 5 * time.Minute
dbPingDefaultRetryInterval = 2 * time.Second
// dbPingAttemptTimeout caps a single PingContext call so a hung connection
// doesn't block the whole timeout budget on one attempt.
dbPingAttemptTimeout = 10 * time.Second
)
var DBPingCmd = &cobra.Command{
Use: "ping",
Short: "Wait for the database to become reachable",
Long: `Pings the configured Mattermost database, retrying until --timeout expires.
Exits 0 once the database accepts a ping. Exits non-zero on timeout or fatal error.
Intended for use as a readiness probe (e.g. a Kubernetes init container).
Resolves the DSN exactly like 'mattermost db migrate' / 'mattermost db init':
the --config flag, then MM_CONFIG, then config.json (which is then loaded as
a config store and SqlSettings.DataSource is used).`,
Example: ` # Database DSN passed via --config (preferred for readiness probes)
$ mattermost db ping --config postgres://mmuser:mostest_password@localhost/mattermost --timeout 2m
# Or via MM_CONFIG
$ MM_CONFIG=postgres://localhost/mattermost mattermost db ping`,
Args: cobra.NoArgs,
RunE: dbPingCmdF,
}
func init() {
DBPingCmd.Flags().Duration("timeout", dbPingDefaultTimeout,
"Maximum total time to wait for the DB to become reachable.")
DBPingCmd.Flags().Duration("retry-interval", dbPingDefaultRetryInterval,
"Sleep between ping attempts.")
DbCmd.AddCommand(DBPingCmd)
}
func dbPingCmdF(command *cobra.Command, _ []string) error {
logger := mlog.CreateConsoleLogger()
defer func() {
_ = logger.Shutdown()
}()
timeout, _ := command.Flags().GetDuration("timeout")
retryInterval, _ := command.Flags().GetDuration("retry-interval")
if timeout <= 0 {
return errors.New("--timeout must be > 0")
}
if retryInterval <= 0 {
return errors.New("--retry-interval must be > 0")
}
dsn, err := resolvePingDataSource(command)
if err != nil {
return err
}
sanitized, err := sanitizePingDataSource(dsn)
if err != nil {
return err
}
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
if err != nil {
return errors.Wrap(err, "failed to open SQL connection")
}
defer db.Close()
// Minimal pool — this is a one-shot readiness probe.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
ctx, cancel := context.WithTimeout(command.Context(), timeout)
defer cancel()
return pingWithRetry(ctx, db, retryInterval, logger.With(
mlog.String("data_source", sanitized),
))
}
func sanitizePingDataSource(dsn string) (string, error) {
sanitized, err := model.SanitizeDataSource(model.DatabaseDriverPostgres, dsn)
if err != nil {
return "", safeDataSourceSanitizationError(err)
}
return sanitized, nil
}
func safeDataSourceSanitizationError(err error) error {
var urlErr *url.Error
if stdErrors.As(err, &urlErr) {
if urlErr.Err != nil {
return errors.Errorf("invalid database DSN: %v", urlErr.Err)
}
return errors.New("invalid database DSN")
}
return errors.New("invalid database DSN")
}
// resolvePingDataSource returns a postgres DSN to ping.
//
// If the configured DSN is a postgres:// / postgresql:// URL it is returned as-is
// (fast path: no config store load required). Otherwise it is treated as a file
// path: a config.Store is loaded read-only (createFileIfNotExist=false so the
// command never has a side effect of creating a config file) and
// SqlSettings.DataSource is returned.
func resolvePingDataSource(command *cobra.Command) (string, error) {
cfgDSN := getConfigDSN(command, config.GetEnvironment())
if config.IsDatabaseDSN(cfgDSN) {
return cfgDSN, nil
}
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true /*readOnly*/, nil /*customDefaults*/, false /*createFileIfNotExist*/)
if err != nil {
return "", errors.Wrapf(err, "failed to load configuration from %q", cfgDSN)
}
defer cfgStore.Close()
sqlSettings := cfgStore.Get().SqlSettings
if sqlSettings.DataSource == nil || *sqlSettings.DataSource == "" {
return "", errors.New("no database DSN configured: set --config or MM_CONFIG to a postgres:// URL, or ensure SqlSettings.DataSource is set in your configuration")
}
if !config.IsDatabaseDSN(*sqlSettings.DataSource) {
// Defensive: the loaded config has a non-postgres DataSource. Mattermost is postgres-only.
return "", errors.New("configured SqlSettings.DataSource is not a postgres DSN")
}
return *sqlSettings.DataSource, nil
}
// pingWithRetry pings db every retryInterval until it succeeds or ctx is done.
// Each individual PingContext call is capped at dbPingAttemptTimeout so a hung
// network connection cannot consume the entire timeout budget on a single try.
func pingWithRetry(ctx context.Context, db *dbsql.DB, retryInterval time.Duration, logger mlog.LoggerIFace) error {
attempt := 0
for {
attempt++
attemptCtx, cancel := context.WithTimeout(ctx, dbPingAttemptTimeout)
err := db.PingContext(attemptCtx)
cancel()
if err == nil {
logger.Info("Database is reachable", mlog.Int("attempt", attempt))
return nil
}
// Surface progress on every attempt so operators can see the probe is alive.
// Intentionally omit the raw error: lib/pq error strings can echo DSN fragments.
logger.Info("Waiting for database",
mlog.Int("attempt", attempt),
mlog.Duration("retry_interval", retryInterval),
mlog.String("status", "ping_failed"),
)
// Wait retryInterval, but bail early if ctx is done.
select {
case <-ctx.Done():
return errors.Wrapf(ctx.Err(), "timed out waiting for database after %d attempts", attempt)
case <-time.After(retryInterval):
}
}
}

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
dbsql "database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// dsnFromHelper builds a postgres:// DSN from the test main helper's SqlSettings.
// The helper itself stores the live test postgres DSN.
func dsnFromHelper(t *testing.T) string {
t.Helper()
require.NotNil(t, mainHelper, "mainHelper must be initialized; do not run with -short")
settings := mainHelper.GetSQLSettings()
require.NotNil(t, settings.DataSource)
require.NotEmpty(t, *settings.DataSource)
return *settings.DataSource
}
// --- subprocess (CLI integration) tests ---
func TestDBPingHappyPath(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
output := th.CheckCommand(t, "db", "ping", "--timeout", "30s")
require.Contains(t, output, "Database is reachable",
"expected success log line in command output, got: %s", output)
}
func TestDBPingDirectDSN(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := dsnFromHelper(t)
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", "--timeout", "30s")
require.NoError(t, err, "command should succeed when DSN is direct postgres URL; output: %s", output)
require.Contains(t, output, "Database is reachable")
}
func TestDBPingTimeoutOnUnreachableDB(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Loopback to a port nothing listens on; connect_timeout=1 keeps each
// attempt short. We allow 2s total with 500ms between attempts so we get
// multiple "Waiting for database" lines.
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err, "command should fail on unreachable DB; output: %s", output)
require.Contains(t, output, "timed out waiting for database",
"expected timeout message in output, got: %s", output)
require.LessOrEqual(t, elapsed, 30*time.Second,
"command should not exceed a generous upper bound; took %s", elapsed)
}
func TestDBPingInvalidDSN(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Passes IsDatabaseDSN (postgres:// prefix) so it takes the direct path.
dsn := "postgres://leakyuser:supersecret@[invalid"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail on malformed DSN; output: %s", output)
require.Contains(t, output, "invalid database DSN",
"expected sanitized DSN parse error; got: %s", output)
require.Contains(t, output, "missing ']' in host",
"expected malformed DSN reason; got: %s", output)
require.NotContains(t, output, "supersecret",
"malformed DSN errors must not leak credentials; got: %s", output)
require.NotContains(t, output, "leakyuser",
"malformed DSN errors must not leak credentials; got: %s", output)
}
func TestDBPingMissingConfigFile(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Point --config at a path that does not exist; createFileIfNotExist=false
// inside resolvePingDataSource means NewStoreFromDSN will return an error.
missing := th.TemporaryDirectory() + "/does-not-exist.json"
output, err := th.RunCommandWithOutput(t, "--config", missing, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail when --config file does not exist; output: %s", output)
require.Contains(t, output, "failed to load configuration",
"expected config-load error message; got: %s", output)
}
func TestDBPingFlagValidation(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://localhost:1/mattermost?sslmode=disable&connect_timeout=1"
t.Run("zero timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "0s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error; got: %s", output)
})
t.Run("zero retry interval", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "1s", "--retry-interval", "0s")
require.Error(t, err)
require.Contains(t, output, "--retry-interval must be > 0",
"expected retry-interval validation error; got: %s", output)
})
t.Run("negative timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "-1s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error for negative value; got: %s", output)
})
t.Run("garbage timeout value", func(t *testing.T) {
// cobra refuses to parse "garbage" as a duration; subcommand never runs.
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "garbage")
require.Error(t, err)
// Don't pin to exact text — cobra owns the error string here. Just
// confirm the subcommand's success log is absent.
require.NotContains(t, output, "Database is reachable",
"command should not have run successfully; got: %s", output)
})
}
func TestDBPingRetryIntervalHonored(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err)
// Loose lower bound: at 500ms intervals we expect at least 2 retries
// (~1s wall clock minimum) before the 2s timeout strikes.
require.GreaterOrEqual(t, elapsed, 1*time.Second,
"expected retries to span at least 1s; got %s", elapsed)
// Loose upper bound: don't exceed several multiples of the configured
// timeout — accommodates CI variance.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected command to bail close to --timeout; took %s", elapsed)
waitingCount := strings.Count(output, "Waiting for database")
require.GreaterOrEqual(t, waitingCount, 2,
"expected at least 2 'Waiting for database' lines; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingShortRetryIntervalProducesMoreAttempts verifies that the
// --retry-interval flag actually controls the cadence (not just the timeout).
func TestDBPingShortRetryIntervalProducesMoreAttempts(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "3s", "--retry-interval", "200ms")
require.Error(t, err)
waitingCount := strings.Count(output, "Waiting for database")
// At 200ms intervals over 3s we expect well more than 3 attempts even
// accounting for per-attempt connection overhead.
require.GreaterOrEqual(t, waitingCount, 3,
"expected several retries with short interval; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingCmdRegistered confirms the new subcommand is wired into the
// existing DbCmd group, so users actually get `mattermost db ping`.
func TestDBPingCmdRegistered(t *testing.T) {
require.Contains(t, DbCmd.Commands(), DBPingCmd,
"DBPingCmd should be registered as a subcommand of DbCmd")
require.Equal(t, "ping", DBPingCmd.Use)
// Flags exist with sensible defaults.
timeoutFlag := DBPingCmd.Flags().Lookup("timeout")
require.NotNil(t, timeoutFlag)
require.Equal(t, dbPingDefaultTimeout.String(), timeoutFlag.DefValue)
intervalFlag := DBPingCmd.Flags().Lookup("retry-interval")
require.NotNil(t, intervalFlag)
require.Equal(t, dbPingDefaultRetryInterval.String(), intervalFlag.DefValue)
}
// --- in-process tests of pingWithRetry / resolvePingDataSource ---
func TestPingWithRetry_SuccessOnFirstAttempt(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
dsn := dsnFromHelper(t)
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 100*time.Millisecond, logger)
require.NoError(t, err)
}
func TestPingWithRetry_TimeoutAgainstUnreachable(t *testing.T) {
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 200*time.Millisecond, logger)
elapsed := time.Since(start)
require.Error(t, err)
require.True(t,
errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(err.Error(), "timed out waiting for database"),
"expected deadline-exceeded or timeout error; got %v", err)
// Must have honored the timeout, not just returned immediately.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected reasonable upper bound; took %s", elapsed)
}
func TestPingWithRetry_ContextCancelImmediately(t *testing.T) {
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before we even start
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 1*time.Second, logger)
require.Error(t, err, "cancelled context should produce an error")
}
// In-process tests of resolvePingDataSource. We drive DSN selection via the
// MM_CONFIG environment variable rather than the --config persistent flag
// because the persistent flag is only merged into a subcommand's local
// flagset during cobra's Execute() pipeline; calling resolvePingDataSource
// directly outside Execute means the flag would not be visible.
// MM_CONFIG is consumed by getConfigDSN as the second-precedence source.
func TestResolvePingDataSource_DirectDSN(t *testing.T) {
wanted := "postgres://user:pw@example.invalid:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_DirectDSN_PostgresqlScheme(t *testing.T) {
wanted := "postgresql://user:pw@example.invalid:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_MissingFile(t *testing.T) {
missing := t.TempDir() + "/no-such-config.json"
t.Setenv("MM_CONFIG", missing)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration")
}
// TestResolvePingDataSource_PointsAtDirectory verifies that pointing --config
// at a directory (not a JSON file) surfaces a clear, wrapped error. Catches
// regressions where we silently fall through instead of returning the load error.
func TestResolvePingDataSource_PointsAtDirectory(t *testing.T) {
dir := t.TempDir()
t.Setenv("MM_CONFIG", dir)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration",
"expected wrapped error; got %v", err)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"flag"
"testing"
"github.com/stretchr/testify/require"
)
func TestExecCommand(t *testing.T) {
if filter := flag.Lookup("test.run").Value.String(); filter != "ExecCommand" {
t.Skip("use -run ExecCommand to execute a command via the test executable")
}
RootCmd.SetArgs(flag.Args())
require.NoError(t, RootCmd.Execute())
}

View File

@@ -0,0 +1,187 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var ExportCmd = &cobra.Command{
Use: "export",
Short: "Export data from Mattermost",
Long: "Export data from Mattermost in a format suitable for import into a third-party application or another Mattermost instance",
}
var ScheduleExportCmd = &cobra.Command{
Use: "schedule",
Short: "Schedule an export data job in Mattermost",
Long: "Schedule an export data job in Mattermost (this will run asynchronously via a background worker)",
Example: "export schedule --format=actiance --exportFrom=12345 --timeoutSeconds=12345",
RunE: scheduleExportCmdF,
}
var BulkExportCmd = &cobra.Command{
Use: "bulk [file]",
Short: "Export bulk data.",
Long: "Export data to a file compatible with the Mattermost Bulk Import format.",
Example: "export bulk bulk_data.json",
RunE: bulkExportCmdF,
Args: cobra.ExactArgs(1),
}
func init() {
ScheduleExportCmd.Flags().String("format", "actiance", "The format to export data")
ScheduleExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
ScheduleExportCmd.Flags().Int("timeoutSeconds", -1, "The maximum number of seconds to wait for the job to complete before timing out.")
BulkExportCmd.Flags().Bool("all-teams", true, "Export all teams from the server.")
BulkExportCmd.Flags().Bool("with-archived-channels", false, "Also exports archived channels.")
BulkExportCmd.Flags().Bool("with-profile-pictures", false, "Also exports profile pictures.")
BulkExportCmd.Flags().Bool("attachments", false, "Also export file attachments.")
BulkExportCmd.Flags().Bool("archive", false, "Outputs a single archive file.")
ExportCmd.AddCommand(ScheduleExportCmd)
ExportCmd.AddCommand(BulkExportCmd)
RootCmd.AddCommand(ExportCmd)
}
func scheduleExportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
if err != nil {
return err
}
defer a.Srv().Shutdown()
if !*a.Config().MessageExportSettings.EnableExport {
return errors.New("ERROR: The message export feature is not enabled")
}
var rctx request.CTX = request.EmptyContext(a.Log())
// for now, format is hard-coded to actiance. In time, we'll have to support other formats and inject them into job data
format, err := command.Flags().GetString("format")
if err != nil {
return errors.New("format flag error")
}
if format != "actiance" {
return errors.New("unsupported export format")
}
startTime, err := command.Flags().GetInt64("exportFrom")
if err != nil {
return errors.New("exportFrom flag error")
}
if startTime < 0 {
return errors.New("exportFrom must be a positive integer")
}
timeoutSeconds, err := command.Flags().GetInt("timeoutSeconds")
if err != nil {
return errors.New("timeoutSeconds error")
}
if timeoutSeconds < 0 {
return errors.New("timeoutSeconds must be a positive integer")
}
if messageExportI := a.MessageExport(); messageExportI != nil {
ctx := context.Background()
if timeoutSeconds > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Second*time.Duration(timeoutSeconds))
defer cancel()
}
rctx = rctx.WithContext(ctx)
job, err := messageExportI.StartSynchronizeJob(rctx, startTime)
if err != nil || job.Status == model.JobStatusError || job.Status == model.JobStatusCanceled {
CommandPrintErrorln("ERROR: Message export job failed. Please check the server logs")
} else {
CommandPrettyPrintln("SUCCESS: Message export job complete")
auditRec := a.MakeAuditRecord(rctx, model.AuditEventScheduleExport, model.AuditStatusSuccess)
auditRec.AddMeta("format", format)
auditRec.AddMeta("start", startTime)
a.LogAuditRec(rctx, auditRec, nil)
}
}
return nil
}
func bulkExportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
if err != nil {
return err
}
defer a.Srv().Shutdown()
rctx := request.EmptyContext(a.Log())
allTeams, err := command.Flags().GetBool("all-teams")
if err != nil {
return errors.Wrap(err, "all-teams flag error")
}
if !allTeams {
return errors.New("Nothing to export. Please specify the --all-teams flag to export all teams.")
}
attachments, err := command.Flags().GetBool("attachments")
if err != nil {
return errors.Wrap(err, "attachments flag error")
}
archive, err := command.Flags().GetBool("archive")
if err != nil {
return errors.Wrap(err, "archive flag error")
}
withArchivedChannels, err := command.Flags().GetBool("with-archived-channels")
if err != nil {
return errors.Wrap(err, "with-archived-channels flag error")
}
includeProfilePictures, err := command.Flags().GetBool("with-profile-pictures")
if err != nil {
return errors.Wrap(err, "with-profile-pictures flag error")
}
fileWriter, err := os.Create(args[0])
if err != nil {
return err
}
defer fileWriter.Close()
outPath, err := filepath.Abs(args[0])
if err != nil {
return err
}
var opts model.BulkExportOpts
opts.IncludeAttachments = attachments
opts.CreateArchive = archive
opts.IncludeArchivedChannels = withArchivedChannels
opts.IncludeProfilePictures = includeProfilePictures
if err := a.BulkExport(rctx, fileWriter, filepath.Dir(outPath), nil /* nil job since it's spawned from CLI */, opts); err != nil {
CommandPrintErrorln(err.Error())
return err
}
auditRec := a.MakeAuditRecord(rctx, model.AuditEventBulkExport, model.AuditStatusSuccess)
auditRec.AddMeta("all_teams", allTeams)
auditRec.AddMeta("file", args[0])
a.LogAuditRec(rctx, auditRec, nil)
return 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 (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Import data.",
}
var BulkImportCmd = &cobra.Command{
Use: "bulk [file]",
Short: "Import bulk data.",
Long: "Import data from a Mattermost Bulk Import File.",
Example: " import bulk bulk_data.json",
RunE: bulkImportCmdF,
}
func init() {
BulkImportCmd.Flags().Bool("apply", false, "Save the import data to the database. Use with caution - this cannot be reverted.")
BulkImportCmd.Flags().Bool("validate", false, "Validate the import data without making any changes to the system.")
BulkImportCmd.Flags().Int("workers", 2, "How many workers to run whilst doing the import.")
BulkImportCmd.Flags().String("import-path", "", "A path to the data directory to import files from.")
ImportCmd.AddCommand(
BulkImportCmd,
)
RootCmd.AddCommand(ImportCmd)
}
func bulkImportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command)
if err != nil {
return err
}
defer a.Srv().Shutdown()
rctx := request.EmptyContext(a.Log())
apply, err := command.Flags().GetBool("apply")
if err != nil {
return errors.New("Apply flag error")
}
validate, err := command.Flags().GetBool("validate")
if err != nil {
return errors.New("Validate flag error")
}
workers, err := command.Flags().GetInt("workers")
if err != nil {
return errors.New("Workers flag error")
}
importPath, err := command.Flags().GetString("import-path")
if err != nil {
return errors.New("import-path flag error")
}
if len(args) != 1 {
return errors.New("Incorrect number of arguments.")
}
fileReader, err := os.Open(args[0])
if err != nil {
return err
}
defer fileReader.Close()
if apply && validate {
CommandPrettyPrintln("Use only one of --apply or --validate.")
return nil
}
if apply && !validate {
CommandPrettyPrintln("Running Bulk Import. This may take a long time.")
} else {
CommandPrettyPrintln("Running Bulk Import Data Validation.")
CommandPrettyPrintln("** This checks the validity of the entities in the data file, but does not persist any changes **")
CommandPrettyPrintln("Use the --apply flag to perform the actual data import.")
}
CommandPrettyPrintln("")
if lineNumber, err := a.BulkImportWithPath(rctx, fileReader, nil, true, !apply, workers, importPath); err != nil {
CommandPrintErrorln(err.Error())
if lineNumber != 0 {
CommandPrintErrorln(fmt.Sprintf("Error occurred on data file line %v", lineNumber))
}
return err
}
if apply {
CommandPrettyPrintln("Finished Bulk Import.")
auditRec := a.MakeAuditRecord(rctx, model.AuditEventBulkImport, model.AuditStatusSuccess)
auditRec.AddMeta("file", args[0])
a.LogAuditRec(rctx, auditRec, nil)
} else {
CommandPrettyPrintln("Validation complete. You can now perform the import by rerunning this command with the --apply flag.")
}
return nil
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
func initDBCommandContextCobra(command *cobra.Command, readOnlyConfigStore bool, options ...app.Option) (*app.App, error) {
a, err := initDBCommandContext(getConfigDSN(command, config.GetEnvironment()), readOnlyConfigStore, options...)
if err != nil {
// Returning an error just prints the usage message, so actually panic
panic(err)
}
a.InitPlugins(request.EmptyContext(a.Log()), *a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory)
a.DoAppMigrations()
return a, nil
}
func InitDBCommandContextCobra(command *cobra.Command, options ...app.Option) (*app.App, error) {
return initDBCommandContextCobra(command, true, options...)
}
func initDBCommandContext(configDSN string, readOnlyConfigStore bool, options ...app.Option) (*app.App, error) {
if err := utils.TranslationsPreInit(); err != nil {
return nil, err
}
model.AppErrorInit(i18n.T)
// The option order is important as app.Config option reads app.StartMetrics option.
options = append(options, app.Config(configDSN, readOnlyConfigStore, nil))
s, err := app.NewServer(options...)
if err != nil {
return nil, err
}
a := app.New(app.ServerConnector(s.Channels()))
if model.BuildEnterpriseReady == "true" {
a.Srv().LoadLicense()
}
return a, nil
}
func initStoreCommandContextCobra(logger mlog.LoggerIFace, command *cobra.Command) (store.Store, error) {
cfgDSN := getConfigDSN(command, config.GetEnvironment())
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true)
if err != nil {
return nil, errors.Wrap(err, "failed to load configuration")
}
config := cfgStore.Get()
return sqlstore.New(config.SqlSettings, logger, nil)
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/config"
)
var JobserverCmd = &cobra.Command{
Use: "jobserver",
Short: "Start the Mattermost job server",
RunE: jobserverCmdF,
}
func init() {
JobserverCmd.Flags().Bool("nojobs", false, "Do not run jobs on this jobserver.")
JobserverCmd.Flags().Bool("noschedule", false, "Do not schedule jobs from this jobserver.")
RootCmd.AddCommand(JobserverCmd)
}
func jobserverCmdF(command *cobra.Command, args []string) error {
// Options
noJobs, _ := command.Flags().GetBool("nojobs")
noSchedule, _ := command.Flags().GetBool("noschedule")
// Initialize
a, err := initDBCommandContext(getConfigDSN(command, config.GetEnvironment()), false, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
a.Srv().LoadLicense()
rctx := request.EmptyContext(a.Log())
// Run jobs
rctx.Logger().Info("Starting Mattermost job server")
defer rctx.Logger().Info("Stopped Mattermost job server")
if !noJobs {
a.Srv().Jobs.StartWorkers()
defer a.Srv().Jobs.StopWorkers()
}
if !noSchedule {
a.Srv().Jobs.StartSchedulers()
defer a.Srv().Jobs.StopSchedulers()
}
if !noJobs || !noSchedule {
auditRec := a.MakeAuditRecord(rctx, model.AuditEventJobServer, model.AuditStatusSuccess)
a.LogAuditRec(rctx, auditRec, nil)
}
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-signalChan
// Cleanup anything that isn't handled by a defer statement
rctx.Logger().Info("Stopping Mattermost job server")
return nil
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"flag"
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type TestConfig struct {
TestServiceSettings TestServiceSettings
TestTeamSettings TestTeamSettings
TestClientRequirements TestClientRequirements
TestMessageExportSettings TestMessageExportSettings
}
type TestMessageExportSettings struct {
Enableexport bool
Exportformat string
TestGlobalRelaySettings TestGlobalRelaySettings
}
type TestGlobalRelaySettings struct {
Customertype string
Smtpusername string
Smtppassword string
}
type TestServiceSettings struct {
Siteurl string
Websocketurl string
Licensedfieldlocation string
}
type TestTeamSettings struct {
Sitename string
Maxuserperteam int
}
type TestClientRequirements struct {
Androidlatestversion string
Androidminversion string
Desktoplatestversion string
}
type TestNewConfig struct {
TestNewServiceSettings TestNewServiceSettings
TestNewTeamSettings TestNewTeamSettings
}
type TestNewServiceSettings struct {
SiteUrl *string
UseLetsEncrypt *bool
TLSStrictTransportMaxAge *int64
AllowedThemes []string
}
type TestNewTeamSettings struct {
SiteName *string
MaxUserPerTeam *int
}
type TestPluginSettings struct {
Enable *bool
Directory *string `restricted:"true"`
Plugins map[string]map[string]any
PluginStates map[string]*model.PluginState
SignaturePublicKeyFiles []string
}
func TestMain(m *testing.M) {
// Command tests are run by re-invoking the test binary in question, so avoid creating
// another container when we detect same.
flag.Parse()
if filter := flag.Lookup("test.run").Value.String(); filter == "ExecCommand" {
status := m.Run()
os.Exit(status)
return
}
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"os"
)
func CommandPrintln(a ...any) (int, error) {
return fmt.Println(a...)
}
func CommandPrintErrorln(a ...any) (int, error) {
return fmt.Fprintln(os.Stderr, a...)
}
func CommandPrettyPrintln(a ...any) (int, error) {
return fmt.Fprintln(os.Stdout, a...)
}

View File

@@ -0,0 +1,38 @@
// 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/shared/mlog"
"github.com/spf13/cobra"
)
type Command = cobra.Command
func Run(args []string) error {
RootCmd.SetArgs(args)
return RootCmd.Execute()
}
var RootCmd = &cobra.Command{
Use: "mattermost",
Short: "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`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
checkForRootUser()
},
}
func init() {
RootCmd.PersistentFlags().StringP("config", "c", "", "Configuration file to use.")
}
// checkForRootUser logs a warning if the process is running as root
func checkForRootUser() {
if os.Geteuid() == 0 {
mlog.Warn("Running Mattermost as root is not recommended. Please use a non-root user.")
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"net"
"os"
"os/signal"
"runtime/debug"
"runtime/pprof"
"syscall"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/web"
"github.com/mattermost/mattermost/server/v8/channels/wsapi"
"github.com/mattermost/mattermost/server/v8/config"
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Run the Mattermost server",
RunE: serverCmdF,
SilenceUsage: true,
}
func init() {
RootCmd.AddCommand(serverCmd)
RootCmd.RunE = serverCmdF
}
func serverCmdF(command *cobra.Command, args []string) error {
interruptChan := make(chan os.Signal, 1)
if err := utils.TranslationsPreInit(); err != nil {
return errors.Wrap(err, "unable to load Mattermost translation files")
}
customDefaults, err := loadCustomDefaults()
if err != nil {
mlog.Warn("Error loading custom configuration defaults: " + err.Error())
}
configStore, err := config.NewStoreFromDSN(getConfigDSN(command, config.GetEnvironment()), false, customDefaults, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
defer configStore.Close()
return runServer(configStore, interruptChan)
}
func runServer(configStore *config.Store, interruptChan chan os.Signal) error {
// Setting the highest traceback level from the code.
// This is done to print goroutines from all threads (see golang.org/issue/13161)
// and also preserve a crash dump for later investigation.
debug.SetTraceback("crash")
options := []app.Option{
// The option order is important as app.Config option reads app.StartMetrics option.
app.StartMetrics,
app.ConfigStore(configStore),
app.RunEssentialJobs,
app.JoinCluster,
}
server, err := app.NewServer(options...)
if err != nil {
mlog.Error(err.Error())
return err
}
defer server.Shutdown()
// We add this after shutdown so that it can be called
// before server shutdown happens as it can close
// the advanced logger and prevent the mlog call from working properly.
defer func() {
// A panic pass-through layer which just logs it
// and sends it upwards.
if x := recover(); x != nil {
var buf bytes.Buffer
pprof.Lookup("goroutine").WriteTo(&buf, 2)
mlog.Error("A panic occurred",
mlog.Any("error", x),
mlog.String("stack", buf.String()))
panic(x)
}
}()
_, err = api4.Init(server)
if err != nil {
mlog.Error(err.Error())
return err
}
wsapi.Init(server)
web.New(server)
err = server.Start()
if err != nil {
mlog.Error(err.Error())
return err
}
notifyReady()
// Wiping off any signal handlers set before.
// This may come from intermediary signal handlers requiring to clean
// up resources before server.Start can finish.
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
// wait for kill signal before attempting to gracefully shutdown
// the running service
signal.Notify(interruptChan, syscall.SIGINT, syscall.SIGTERM)
<-interruptChan
return nil
}
func notifyReady() {
// If the environment vars provide a systemd notification socket,
// notify systemd that the server is ready.
systemdSocket := os.Getenv("NOTIFY_SOCKET")
if systemdSocket != "" {
mlog.Info("Sending systemd READY notification.")
err := sendSystemdReadyNotification(systemdSocket)
if err != nil {
mlog.Error(err.Error())
}
}
}
func sendSystemdReadyNotification(socketPath string) error {
msg := "READY=1"
addr := &net.UnixAddr{
Name: socketPath,
Net: "unixgram",
}
conn, err := net.DialUnix(addr.Net, nil, addr)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Write([]byte(msg))
return err
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"net"
"os"
"syscall"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/config"
)
const (
unitTestListeningPort = "localhost:0"
)
//nolint:golint,unused
type ServerTestHelper struct {
disableConfigWatch bool
interruptChan chan os.Signal
originalInterval int
}
//nolint:golint,unused
func SetupServerTest(tb testing.TB) *ServerTestHelper {
if testing.Short() {
tb.SkipNow()
}
// Build a channel that will be used by the server to receive system signals...
interruptChan := make(chan os.Signal, 1)
// ...and sent it immediately a SIGINT value.
// This will make the server loop stop as soon as it started successfully.
interruptChan <- syscall.SIGINT
// Let jobs poll for termination every 0.2s (instead of every 15s by default)
// Otherwise we would have to wait the whole polling duration before the test
// terminates.
originalInterval := jobs.DefaultWatcherPollingInterval
jobs.DefaultWatcherPollingInterval = 200
th := &ServerTestHelper{
disableConfigWatch: true,
interruptChan: interruptChan,
originalInterval: originalInterval,
}
return th
}
//nolint:golint,unused
func (th *ServerTestHelper) TearDownServerTest() {
jobs.DefaultWatcherPollingInterval = th.originalInterval
}
func TestRunServerSuccess(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
err := runServer(configStore, th.interruptChan)
require.NoError(t, err)
}
func TestRunServerSystemdNotification(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
// Get a random temporary filename for using as a mock systemd socket
socketFile, err := os.CreateTemp("", "mattermost-systemd-mock-socket-")
require.NoError(t, err)
socketPath := socketFile.Name()
os.Remove(socketPath)
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("NOTIFY_SOCKET", socketPath)
// Open the socket connection
addr := &net.UnixAddr{
Name: socketPath,
Net: "unixgram",
}
connection, err := net.ListenUnixgram("unixgram", addr)
require.NoError(t, err)
defer connection.Close()
defer os.Remove(socketPath)
// Listen for socket data
socketReader := make(chan string)
go func(ch chan string) {
buffer := make([]byte, 512)
count, readErr := connection.Read(buffer)
require.NoError(t, readErr)
data := buffer[0:count]
ch <- string(data)
}(socketReader)
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
// Start and stop the server
err = runServer(configStore, th.interruptChan)
require.NoError(t, err)
// Ensure the notification has been sent on the socket and is correct
notification := <-socketReader
require.Equal(t, notification, "READY=1")
}
func TestRunServerNoSystemd(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
// Temporarily remove any Systemd socket defined in the environment
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("NOTIFY_SOCKET", "")
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
err := runServer(configStore, th.interruptChan)
require.NoError(t, err)
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bufio"
"fmt"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/wsapi"
)
var TestCmd = &cobra.Command{
Use: "test",
Short: "Testing Commands",
Hidden: true,
}
var RunWebClientTestsCmd = &cobra.Command{
Use: "web_client_tests",
Short: "Run the web client tests",
RunE: webClientTestsCmdF,
}
var RunServerForWebClientTestsCmd = &cobra.Command{
Use: "web_client_tests_server",
Short: "Run the server configured for running the web client tests against it",
RunE: serverForWebClientTestsCmdF,
}
func init() {
TestCmd.AddCommand(
RunWebClientTestsCmd,
RunServerForWebClientTestsCmd,
)
RootCmd.AddCommand(TestCmd)
}
func webClientTestsCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
i18n.InitTranslations(*a.Config().LocalizationSettings.DefaultServerLocale, *a.Config().LocalizationSettings.DefaultClientLocale)
serverErr := a.Srv().Start()
if serverErr != nil {
return serverErr
}
_, err = api4.Init(a.Srv())
if err != nil {
return err
}
wsapi.Init(a.Srv())
a.UpdateConfig(setupClientTests)
runWebClientTests()
return nil
}
func serverForWebClientTestsCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
i18n.InitTranslations(*a.Config().LocalizationSettings.DefaultServerLocale, *a.Config().LocalizationSettings.DefaultClientLocale)
serverErr := a.Srv().Start()
if serverErr != nil {
return serverErr
}
_, err = api4.Init(a.Srv())
if err != nil {
return err
}
wsapi.Init(a.Srv())
a.UpdateConfig(setupClientTests)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-c
return nil
}
func setupClientTests(cfg *model.Config) {
*cfg.TeamSettings.EnableOpenServer = true
*cfg.ServiceSettings.EnableCommands = false
*cfg.ServiceSettings.EnableCustomEmoji = true
*cfg.ServiceSettings.EnableIncomingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingOAuthConnections = false
}
func executeTestCommand(command *exec.Cmd) {
cmdOutPipe, err := command.StdoutPipe()
if err != nil {
CommandPrintErrorln("Failed to run tests")
os.Exit(1)
return
}
cmdErrOutPipe, err := command.StderrPipe()
if err != nil {
CommandPrintErrorln("Failed to run tests")
os.Exit(1)
return
}
cmdOutReader := bufio.NewScanner(cmdOutPipe)
cmdErrOutReader := bufio.NewScanner(cmdErrOutPipe)
go func() {
for cmdOutReader.Scan() {
fmt.Println(cmdOutReader.Text())
}
}()
go func() {
for cmdErrOutReader.Scan() {
fmt.Println(cmdErrOutReader.Text())
}
}()
if err := command.Run(); err != nil {
CommandPrintErrorln("Client Tests failed")
os.Exit(1)
return
}
}
func runWebClientTests() {
if webappDir := os.Getenv("WEBAPP_DIR"); webappDir != "" {
os.Chdir(webappDir)
} else {
os.Chdir("../mattermost-webapp")
}
cmd := exec.Command("npm", "test")
executeTestCommand(cmd)
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"strings"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
)
const CustomDefaultsEnvVar = "MM_CUSTOM_DEFAULTS_PATH"
// printStringMap takes a reflect.Value and prints it out alphabetically based on key values, which must be strings.
// This is done recursively if it's a map, and uses the given tab settings.
func printStringMap(value reflect.Value, tabVal int) string {
out := &bytes.Buffer{}
var sortedKeys []string
stringToKeyMap := make(map[string]reflect.Value)
for _, k := range value.MapKeys() {
sortedKeys = append(sortedKeys, k.String())
stringToKeyMap[k.String()] = k
}
slices.Sort(sortedKeys)
for _, keyString := range sortedKeys {
key := stringToKeyMap[keyString]
val := value.MapIndex(key)
if newVal, ok := val.Interface().(map[string]any); !ok {
fmt.Fprintf(out, "%s", strings.Repeat("\t", tabVal))
fmt.Fprintf(out, "%v: \"%v\"\n", key.Interface(), val.Interface())
} else {
fmt.Fprintf(out, "%s", strings.Repeat("\t", tabVal))
fmt.Fprintf(out, "%v:\n", key.Interface())
// going one level in, increase the tab
tabVal++
fmt.Fprintf(out, "%s", printStringMap(reflect.ValueOf(newVal), tabVal))
// coming back one level, decrease the tab
tabVal--
}
}
return out.String()
}
func getConfigDSN(command *cobra.Command, env map[string]string) string {
configDSN, _ := command.Flags().GetString("config")
// Config not supplied in flag, check env
if configDSN == "" {
configDSN = env["MM_CONFIG"]
}
// Config not supplied in env or flag use default
if configDSN == "" {
configDSN = "config.json"
}
return configDSN
}
func loadCustomDefaults() (*model.Config, error) {
customDefaultsPath := os.Getenv(CustomDefaultsEnvVar)
if customDefaultsPath == "" {
return nil, nil
}
file, err := os.Open(customDefaultsPath)
if err != nil {
return nil, fmt.Errorf("unable to open custom defaults file at %q: %w", customDefaultsPath, err)
}
defer file.Close()
var customDefaults *model.Config
err = json.NewDecoder(file).Decode(&customDefaults)
if err != nil {
return nil, fmt.Errorf("unable to decode custom defaults configuration: %w", err)
}
return customDefaults, nil
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"reflect"
"slices"
"strings"
"testing"
)
func TestPrintMap(t *testing.T) {
inputCases := []any{
map[string]any{
"CustomerType": "A9",
"SmtpUsername": "",
"SmtpPassword": "",
"EmailAddress": "",
},
map[string]any{
"EnableExport": false,
"ExportFormat": "actiance",
"DailyRunTime": "01:00",
"GlobalRelaySettings": map[string]any{
"CustomerType": "A9",
"SmtpUsername": "",
"SmtpPassword": "",
"EmailAddress": "",
},
},
}
outputCases := []string{
"CustomerType: \"A9\"\nSmtpUsername: \"\"\nSmtpPassword: \"\"\nEmailAddress: \"\"\n",
"EnableExport: \"false\"\nExportFormat: \"actiance\"\nDailyRunTime: \"01:00\"\nGlobalRelaySettings:\n\t CustomerType: \"A9\"\n\tSmtpUsername: \"\"\n\tSmtpPassword: \"\"\n\tEmailAddress: \"\"\n",
}
cases := []struct {
Name string
Input reflect.Value
Expected string
}{
{
Name: "Basic print",
Input: reflect.ValueOf(inputCases[0]),
Expected: outputCases[0],
},
{
Name: "Complex print",
Input: reflect.ValueOf(inputCases[1]),
Expected: outputCases[1],
},
}
for _, test := range cases {
t.Run(test.Name, func(t *testing.T) {
res := printStringMap(test.Input, 0)
// create two slice of string formed by splitting our strings on \n
slice1 := strings.Split(res, "\n")
slice2 := strings.Split(res, "\n")
slices.Sort(slice1)
slices.Sort(slice2)
if !reflect.DeepEqual(slice1, slice2) {
t.Errorf("got '%#v' want '%#v", slice1, slice2)
}
})
}
}

View File

@@ -0,0 +1,30 @@
// 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"
)
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Display version information",
RunE: versionCmdF,
}
func init() {
RootCmd.AddCommand(VersionCmd)
}
func versionCmdF(command *cobra.Command, args []string) error {
CommandPrintln("Version: " + model.CurrentVersion)
CommandPrintln("Build Number: " + model.BuildNumber)
CommandPrintln("Build Date: " + model.BuildDate)
CommandPrintln("Build Hash: " + model.BuildHash)
CommandPrintln("Build Enterprise Ready: " + model.BuildEnterpriseReady)
return nil
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"testing"
)
func TestVersion(t *testing.T) {
if testing.Short() {
t.Skip("skipping version test in short mode")
}
th := SetupWithStoreMock(t)
th.CheckCommand(t, "version")
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"os"
"github.com/mattermost/mattermost/server/v8/cmd/mattermost/commands"
// Import and register app layer slash commands
_ "github.com/mattermost/mattermost/server/v8/channels/app/slashcommands"
// Plugins
_ "github.com/mattermost/mattermost/server/v8/channels/app/oauthproviders/gitlab"
// Enterprise Imports
_ "github.com/mattermost/mattermost/server/v8/enterprise"
)
func main() {
if err := commands.Run(os.Args[1:]); err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build maincoverage
package main
import (
"testing"
)
// TestRunMain can be used to track code coverage in integration tests.
// To run this:
// go test -coverpkg="<>" -ldflags '<>' -tags maincoverage -c ./cmd/mattermost/
// ./mattermost.test -test.run="^TestRunMain$" -test.coverprofile=coverage.out
// And then run your integration tests.
func TestRunMain(t *testing.T) {
main()
}

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())
})
}

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