From 951456c78031d2a43aa73fb145887b91aa2b542f Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 5 Jun 2023 12:42:55 +0200 Subject: [PATCH] Includes mmctl into the mono-repo (#23091) * Includes mmctl into the mono-repo * Update to use the new public module paths * Adds docs check to the mmctl CI * Fix public utils import path * Tidy up modules * Fix linter * Update CI tasks to use the new file structure * Update CI references --- .github/workflows/mmctl-test-template.yml | 50 + .github/workflows/server-ci-template.yml | 26 + .github/workflows/server-test-template.yml | 2 +- server/.gitignore | 2 + server/Makefile | 60 +- server/build/release.mk | 23 +- server/cmd/mmctl/client/client.go | 149 + server/cmd/mmctl/commands/auth.go | 428 +++ server/cmd/mmctl/commands/auth_e2e_test.go | 35 + server/cmd/mmctl/commands/auth_utils.go | 231 ++ server/cmd/mmctl/commands/auth_utils_test.go | 158 + server/cmd/mmctl/commands/bot.go | 284 ++ server/cmd/mmctl/commands/bot_e2e_test.go | 434 +++ server/cmd/mmctl/commands/bot_test.go | 771 +++++ server/cmd/mmctl/commands/channel.go | 629 ++++ server/cmd/mmctl/commands/channel_e2e_test.go | 621 ++++ server/cmd/mmctl/commands/channel_test.go | 2953 +++++++++++++++++ server/cmd/mmctl/commands/channel_users.go | 135 + .../mmctl/commands/channel_users_e2e_test.go | 290 ++ .../cmd/mmctl/commands/channel_users_test.go | 441 +++ server/cmd/mmctl/commands/channelargs.go | 124 + server/cmd/mmctl/commands/channelargs_test.go | 113 + server/cmd/mmctl/commands/command.go | 351 ++ server/cmd/mmctl/commands/command_e2e_test.go | 319 ++ server/cmd/mmctl/commands/command_test.go | 1062 ++++++ server/cmd/mmctl/commands/commandargs.go | 55 + server/cmd/mmctl/commands/completion.go | 204 ++ server/cmd/mmctl/commands/config.go | 569 ++++ server/cmd/mmctl/commands/config_e2e_test.go | 236 ++ server/cmd/mmctl/commands/config_test.go | 877 +++++ server/cmd/mmctl/commands/docs.go | 48 + server/cmd/mmctl/commands/enterprise.go | 24 + server/cmd/mmctl/commands/errors.go | 52 + server/cmd/mmctl/commands/export.go | 255 ++ server/cmd/mmctl/commands/export_e2e_test.go | 452 +++ server/cmd/mmctl/commands/export_test.go | 119 + server/cmd/mmctl/commands/extract.go | 120 + server/cmd/mmctl/commands/extract_e2e_test.go | 187 ++ server/cmd/mmctl/commands/group.go | 345 ++ server/cmd/mmctl/commands/group_e2e_test.go | 557 ++++ server/cmd/mmctl/commands/group_test.go | 1578 +++++++++ server/cmd/mmctl/commands/import.go | 521 +++ server/cmd/mmctl/commands/import_e2e_test.go | 367 ++ server/cmd/mmctl/commands/import_test.go | 226 ++ server/cmd/mmctl/commands/importer/utils.go | 66 + .../cmd/mmctl/commands/importer/validate.go | 1050 ++++++ server/cmd/mmctl/commands/init.go | 233 ++ server/cmd/mmctl/commands/init_test.go | 195 ++ server/cmd/mmctl/commands/integrity.go | 103 + server/cmd/mmctl/commands/integrity_test.go | 114 + server/cmd/mmctl/commands/ldap.go | 82 + server/cmd/mmctl/commands/ldap_e2e_test.go | 129 + server/cmd/mmctl/commands/ldap_test.go | 115 + server/cmd/mmctl/commands/license.go | 95 + server/cmd/mmctl/commands/license_e2e_test.go | 84 + server/cmd/mmctl/commands/license_test.go | 126 + server/cmd/mmctl/commands/logs.go | 58 + server/cmd/mmctl/commands/logs_e2e_test.go | 44 + server/cmd/mmctl/commands/logs_test.go | 148 + server/cmd/mmctl/commands/main_test.go | 27 + server/cmd/mmctl/commands/mmctl_e2e_test.go | 17 + server/cmd/mmctl/commands/mmctl_test.go | 98 + server/cmd/mmctl/commands/mmctl_unit_test.go | 17 + .../mmctl/commands/permission_role_test.go | 467 +++ server/cmd/mmctl/commands/permissions.go | 183 + .../mmctl/commands/permissions_e2e_test.go | 110 + .../commands/permissions_reset_e2e_test.go | 82 + server/cmd/mmctl/commands/permissions_role.go | 226 ++ .../commands/permissions_role_e2e_test.go | 104 + server/cmd/mmctl/commands/permissions_test.go | 258 ++ server/cmd/mmctl/commands/plugin.go | 196 ++ server/cmd/mmctl/commands/plugin_e2e_test.go | 381 +++ .../cmd/mmctl/commands/plugin_marketplace.go | 118 + .../commands/plugin_marketplace_e2e_test.go | 126 + .../mmctl/commands/plugin_marketplace_test.go | 166 + server/cmd/mmctl/commands/plugin_test.go | 620 ++++ server/cmd/mmctl/commands/post.go | 218 ++ server/cmd/mmctl/commands/post_e2e_test.go | 194 ++ server/cmd/mmctl/commands/post_test.go | 258 ++ server/cmd/mmctl/commands/roles.go | 132 + server/cmd/mmctl/commands/roles_test.go | 221 ++ server/cmd/mmctl/commands/root.go | 95 + server/cmd/mmctl/commands/saml.go | 74 + server/cmd/mmctl/commands/saml_test.go | 78 + server/cmd/mmctl/commands/sampledata.go | 413 +++ server/cmd/mmctl/commands/sampledata_test.go | 64 + server/cmd/mmctl/commands/sampledata_util.go | 451 +++ server/cmd/mmctl/commands/system.go | 153 + server/cmd/mmctl/commands/system_e2e_test.go | 105 + server/cmd/mmctl/commands/system_test.go | 211 ++ server/cmd/mmctl/commands/team.go | 378 +++ server/cmd/mmctl/commands/team_e2e_test.go | 479 +++ server/cmd/mmctl/commands/team_test.go | 909 +++++ server/cmd/mmctl/commands/team_users.go | 108 + .../cmd/mmctl/commands/team_users_e2e_test.go | 232 ++ server/cmd/mmctl/commands/team_users_test.go | 370 +++ server/cmd/mmctl/commands/teamargs.go | 90 + server/cmd/mmctl/commands/teamargs_test.go | 100 + server/cmd/mmctl/commands/token.go | 132 + server/cmd/mmctl/commands/token_e2e_test.go | 79 + server/cmd/mmctl/commands/token_test.go | 296 ++ server/cmd/mmctl/commands/user.go | 1000 ++++++ server/cmd/mmctl/commands/user_e2e_test.go | 1069 ++++++ server/cmd/mmctl/commands/user_test.go | 2736 +++++++++++++++ server/cmd/mmctl/commands/userargs.go | 120 + server/cmd/mmctl/commands/userargs_test.go | 110 + server/cmd/mmctl/commands/utils.go | 113 + server/cmd/mmctl/commands/utils_unix.go | 80 + server/cmd/mmctl/commands/utils_unix_test.go | 50 + server/cmd/mmctl/commands/utils_windows.go | 49 + server/cmd/mmctl/commands/version.go | 64 + server/cmd/mmctl/commands/webhook.go | 476 +++ server/cmd/mmctl/commands/webhook_e2e_test.go | 39 + server/cmd/mmctl/commands/webhook_test.go | 698 ++++ server/cmd/mmctl/commands/websockets.go | 44 + server/cmd/mmctl/docs/mmctl.rst | 61 + server/cmd/mmctl/docs/mmctl_auth.rst | 47 + server/cmd/mmctl/docs/mmctl_auth_clean.rst | 51 + server/cmd/mmctl/docs/mmctl_auth_current.rst | 51 + server/cmd/mmctl/docs/mmctl_auth_delete.rst | 51 + server/cmd/mmctl/docs/mmctl_auth_list.rst | 51 + server/cmd/mmctl/docs/mmctl_auth_login.rst | 60 + server/cmd/mmctl/docs/mmctl_auth_renew.rst | 54 + server/cmd/mmctl/docs/mmctl_auth_set.rst | 51 + server/cmd/mmctl/docs/mmctl_bot.rst | 46 + server/cmd/mmctl/docs/mmctl_bot_assign.rst | 51 + server/cmd/mmctl/docs/mmctl_bot_create.rst | 54 + server/cmd/mmctl/docs/mmctl_bot_disable.rst | 51 + server/cmd/mmctl/docs/mmctl_bot_enable.rst | 51 + server/cmd/mmctl/docs/mmctl_bot_list.rst | 53 + server/cmd/mmctl/docs/mmctl_bot_update.rst | 54 + server/cmd/mmctl/docs/mmctl_channel.rst | 50 + .../cmd/mmctl/docs/mmctl_channel_archive.rst | 53 + .../cmd/mmctl/docs/mmctl_channel_create.rst | 58 + .../cmd/mmctl/docs/mmctl_channel_delete.rst | 53 + server/cmd/mmctl/docs/mmctl_channel_list.rst | 53 + .../cmd/mmctl/docs/mmctl_channel_modify.rst | 55 + server/cmd/mmctl/docs/mmctl_channel_move.rst | 54 + .../cmd/mmctl/docs/mmctl_channel_rename.rst | 55 + .../cmd/mmctl/docs/mmctl_channel_search.rst | 55 + .../mmctl/docs/mmctl_channel_unarchive.rst | 52 + server/cmd/mmctl/docs/mmctl_channel_users.rst | 42 + .../mmctl/docs/mmctl_channel_users_add.rst | 51 + .../mmctl/docs/mmctl_channel_users_remove.rst | 53 + server/cmd/mmctl/docs/mmctl_command.rst | 46 + .../cmd/mmctl/docs/mmctl_command_archive.rst | 51 + .../cmd/mmctl/docs/mmctl_command_create.rst | 62 + server/cmd/mmctl/docs/mmctl_command_list.rst | 51 + .../cmd/mmctl/docs/mmctl_command_modify.rst | 62 + server/cmd/mmctl/docs/mmctl_command_move.rst | 51 + server/cmd/mmctl/docs/mmctl_command_show.rst | 51 + server/cmd/mmctl/docs/mmctl_completion.rst | 42 + .../cmd/mmctl/docs/mmctl_completion_bash.rst | 49 + .../cmd/mmctl/docs/mmctl_completion_zsh.rst | 49 + server/cmd/mmctl/docs/mmctl_config.rst | 49 + server/cmd/mmctl/docs/mmctl_config_edit.rst | 51 + server/cmd/mmctl/docs/mmctl_config_get.rst | 51 + .../cmd/mmctl/docs/mmctl_config_migrate.rst | 51 + server/cmd/mmctl/docs/mmctl_config_patch.rst | 51 + server/cmd/mmctl/docs/mmctl_config_reload.rst | 51 + server/cmd/mmctl/docs/mmctl_config_reset.rst | 52 + server/cmd/mmctl/docs/mmctl_config_set.rst | 52 + server/cmd/mmctl/docs/mmctl_config_show.rst | 51 + .../cmd/mmctl/docs/mmctl_config_subpath.rst | 60 + server/cmd/mmctl/docs/mmctl_docs.rst | 45 + server/cmd/mmctl/docs/mmctl_export.rst | 45 + server/cmd/mmctl/docs/mmctl_export_create.rst | 45 + server/cmd/mmctl/docs/mmctl_export_delete.rst | 51 + .../cmd/mmctl/docs/mmctl_export_download.rst | 56 + server/cmd/mmctl/docs/mmctl_export_job.rst | 43 + .../mmctl/docs/mmctl_export_job_cancel.rst | 51 + .../cmd/mmctl/docs/mmctl_export_job_list.rst | 54 + .../cmd/mmctl/docs/mmctl_export_job_show.rst | 51 + server/cmd/mmctl/docs/mmctl_export_list.rst | 44 + server/cmd/mmctl/docs/mmctl_extract.rst | 42 + server/cmd/mmctl/docs/mmctl_extract_job.rst | 42 + .../cmd/mmctl/docs/mmctl_extract_job_list.rst | 54 + .../cmd/mmctl/docs/mmctl_extract_job_show.rst | 51 + server/cmd/mmctl/docs/mmctl_extract_run.rst | 53 + server/cmd/mmctl/docs/mmctl_group.rst | 44 + server/cmd/mmctl/docs/mmctl_group_channel.rst | 44 + .../docs/mmctl_group_channel_disable.rst | 51 + .../mmctl/docs/mmctl_group_channel_enable.rst | 51 + .../mmctl/docs/mmctl_group_channel_list.rst | 51 + .../mmctl/docs/mmctl_group_channel_status.rst | 51 + .../cmd/mmctl/docs/mmctl_group_list-ldap.rst | 51 + server/cmd/mmctl/docs/mmctl_group_team.rst | 44 + .../mmctl/docs/mmctl_group_team_disable.rst | 51 + .../mmctl/docs/mmctl_group_team_enable.rst | 51 + .../cmd/mmctl/docs/mmctl_group_team_list.rst | 51 + .../mmctl/docs/mmctl_group_team_status.rst | 51 + server/cmd/mmctl/docs/mmctl_group_user.rst | 41 + .../mmctl/docs/mmctl_group_user_restore.rst | 51 + server/cmd/mmctl/docs/mmctl_import.rst | 45 + server/cmd/mmctl/docs/mmctl_import_job.rst | 42 + .../cmd/mmctl/docs/mmctl_import_job_list.rst | 54 + .../cmd/mmctl/docs/mmctl_import_job_show.rst | 51 + server/cmd/mmctl/docs/mmctl_import_list.rst | 49 + .../docs/mmctl_import_list_available.rst | 51 + .../docs/mmctl_import_list_incomplete.rst | 51 + .../cmd/mmctl/docs/mmctl_import_process.rst | 51 + server/cmd/mmctl/docs/mmctl_import_upload.rst | 53 + .../cmd/mmctl/docs/mmctl_import_validate.rst | 55 + server/cmd/mmctl/docs/mmctl_integrity.rst | 46 + server/cmd/mmctl/docs/mmctl_ldap.rst | 42 + .../cmd/mmctl/docs/mmctl_ldap_idmigrate.rst | 56 + server/cmd/mmctl/docs/mmctl_ldap_sync.rst | 52 + server/cmd/mmctl/docs/mmctl_license.rst | 43 + .../cmd/mmctl/docs/mmctl_license_remove.rst | 51 + .../docs/mmctl_license_upload-string.rst | 51 + .../cmd/mmctl/docs/mmctl_license_upload.rst | 51 + server/cmd/mmctl/docs/mmctl_logs.rst | 46 + server/cmd/mmctl/docs/mmctl_permissions.rst | 44 + .../cmd/mmctl/docs/mmctl_permissions_add.rst | 52 + .../mmctl/docs/mmctl_permissions_remove.rst | 52 + .../mmctl/docs/mmctl_permissions_reset.rst | 52 + .../cmd/mmctl/docs/mmctl_permissions_role.rst | 43 + .../docs/mmctl_permissions_role_assign.rst | 57 + .../docs/mmctl_permissions_role_show.rst | 51 + .../docs/mmctl_permissions_role_unassign.rst | 57 + server/cmd/mmctl/docs/mmctl_plugin.rst | 47 + server/cmd/mmctl/docs/mmctl_plugin_add.rst | 52 + server/cmd/mmctl/docs/mmctl_plugin_delete.rst | 51 + .../cmd/mmctl/docs/mmctl_plugin_disable.rst | 51 + server/cmd/mmctl/docs/mmctl_plugin_enable.rst | 51 + .../mmctl/docs/mmctl_plugin_install-url.rst | 56 + server/cmd/mmctl/docs/mmctl_plugin_list.rst | 51 + .../mmctl/docs/mmctl_plugin_marketplace.rst | 42 + .../docs/mmctl_plugin_marketplace_install.rst | 51 + .../docs/mmctl_plugin_marketplace_list.rst | 66 + server/cmd/mmctl/docs/mmctl_post.rst | 42 + server/cmd/mmctl/docs/mmctl_post_create.rst | 53 + server/cmd/mmctl/docs/mmctl_post_list.rst | 56 + server/cmd/mmctl/docs/mmctl_roles.rst | 42 + server/cmd/mmctl/docs/mmctl_roles_member.rst | 55 + .../mmctl/docs/mmctl_roles_system-admin.rst | 55 + server/cmd/mmctl/docs/mmctl_saml.rst | 41 + .../mmctl/docs/mmctl_saml_auth-data-reset.rst | 65 + server/cmd/mmctl/docs/mmctl_sampledata.rst | 79 + server/cmd/mmctl/docs/mmctl_system.rst | 45 + .../cmd/mmctl/docs/mmctl_system_clearbusy.rst | 51 + .../cmd/mmctl/docs/mmctl_system_getbusy.rst | 51 + .../cmd/mmctl/docs/mmctl_system_setbusy.rst | 52 + server/cmd/mmctl/docs/mmctl_system_status.rst | 51 + .../cmd/mmctl/docs/mmctl_system_version.rst | 51 + server/cmd/mmctl/docs/mmctl_team.rst | 49 + server/cmd/mmctl/docs/mmctl_team_archive.rst | 53 + server/cmd/mmctl/docs/mmctl_team_create.rst | 56 + server/cmd/mmctl/docs/mmctl_team_delete.rst | 53 + server/cmd/mmctl/docs/mmctl_team_list.rst | 51 + server/cmd/mmctl/docs/mmctl_team_modify.rst | 53 + server/cmd/mmctl/docs/mmctl_team_rename.rst | 52 + server/cmd/mmctl/docs/mmctl_team_restore.rst | 51 + server/cmd/mmctl/docs/mmctl_team_search.rst | 51 + server/cmd/mmctl/docs/mmctl_team_users.rst | 42 + .../cmd/mmctl/docs/mmctl_team_users_add.rst | 51 + .../mmctl/docs/mmctl_team_users_remove.rst | 51 + server/cmd/mmctl/docs/mmctl_token.rst | 43 + .../cmd/mmctl/docs/mmctl_token_generate.rst | 51 + server/cmd/mmctl/docs/mmctl_token_list.rst | 56 + server/cmd/mmctl/docs/mmctl_token_revoke.rst | 51 + server/cmd/mmctl/docs/mmctl_user.rst | 58 + server/cmd/mmctl/docs/mmctl_user_activate.rst | 52 + .../mmctl/docs/mmctl_user_change-password.rst | 68 + server/cmd/mmctl/docs/mmctl_user_convert.rst | 68 + server/cmd/mmctl/docs/mmctl_user_create.rst | 72 + .../cmd/mmctl/docs/mmctl_user_deactivate.rst | 52 + server/cmd/mmctl/docs/mmctl_user_delete.rst | 53 + .../cmd/mmctl/docs/mmctl_user_deleteall.rst | 52 + server/cmd/mmctl/docs/mmctl_user_demote.rst | 51 + server/cmd/mmctl/docs/mmctl_user_email.rst | 51 + server/cmd/mmctl/docs/mmctl_user_invite.rst | 54 + server/cmd/mmctl/docs/mmctl_user_list.rst | 55 + .../mmctl/docs/mmctl_user_migrate-auth.rst | 54 + server/cmd/mmctl/docs/mmctl_user_promote.rst | 51 + .../mmctl/docs/mmctl_user_reset-password.rst | 51 + server/cmd/mmctl/docs/mmctl_user_resetmfa.rst | 52 + server/cmd/mmctl/docs/mmctl_user_search.rst | 51 + server/cmd/mmctl/docs/mmctl_user_username.rst | 51 + server/cmd/mmctl/docs/mmctl_user_verify.rst | 51 + server/cmd/mmctl/docs/mmctl_version.rst | 44 + server/cmd/mmctl/docs/mmctl_webhook.rst | 47 + .../docs/mmctl_webhook_create-incoming.rst | 57 + .../docs/mmctl_webhook_create-outgoing.rst | 62 + .../cmd/mmctl/docs/mmctl_webhook_delete.rst | 51 + server/cmd/mmctl/docs/mmctl_webhook_list.rst | 51 + .../docs/mmctl_webhook_modify-incoming.rst | 56 + .../docs/mmctl_webhook_modify-outgoing.rst | 59 + server/cmd/mmctl/docs/mmctl_webhook_show.rst | 51 + server/cmd/mmctl/docs/mmctl_websocket.rst | 44 + server/cmd/mmctl/mmctl.go | 18 + server/cmd/mmctl/mocks/client_mock.go | 2164 ++++++++++++ server/cmd/mmctl/mocks/copyright.txt | 2 + server/cmd/mmctl/printer/human/entry.go | 52 + .../cmd/mmctl/printer/human/logrus_writer.go | 77 + server/cmd/mmctl/printer/human/parser.go | 180 + server/cmd/mmctl/printer/human/process.go | 23 + .../cmd/mmctl/printer/human/simple_writer.go | 23 + server/cmd/mmctl/printer/keys.go | 57 + server/cmd/mmctl/printer/printer.go | 325 ++ server/cmd/mmctl/printer/printer_test.go | 161 + server/cmd/mmctl/printer/util.go | 38 + server/go.mod | 33 +- server/go.sum | 88 +- server/scripts/download_mmctl_release.sh | 63 - 305 files changed, 47027 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/mmctl-test-template.yml create mode 100644 server/cmd/mmctl/client/client.go create mode 100644 server/cmd/mmctl/commands/auth.go create mode 100644 server/cmd/mmctl/commands/auth_e2e_test.go create mode 100644 server/cmd/mmctl/commands/auth_utils.go create mode 100644 server/cmd/mmctl/commands/auth_utils_test.go create mode 100644 server/cmd/mmctl/commands/bot.go create mode 100644 server/cmd/mmctl/commands/bot_e2e_test.go create mode 100644 server/cmd/mmctl/commands/bot_test.go create mode 100644 server/cmd/mmctl/commands/channel.go create mode 100644 server/cmd/mmctl/commands/channel_e2e_test.go create mode 100644 server/cmd/mmctl/commands/channel_test.go create mode 100644 server/cmd/mmctl/commands/channel_users.go create mode 100644 server/cmd/mmctl/commands/channel_users_e2e_test.go create mode 100644 server/cmd/mmctl/commands/channel_users_test.go create mode 100644 server/cmd/mmctl/commands/channelargs.go create mode 100644 server/cmd/mmctl/commands/channelargs_test.go create mode 100644 server/cmd/mmctl/commands/command.go create mode 100644 server/cmd/mmctl/commands/command_e2e_test.go create mode 100644 server/cmd/mmctl/commands/command_test.go create mode 100644 server/cmd/mmctl/commands/commandargs.go create mode 100644 server/cmd/mmctl/commands/completion.go create mode 100644 server/cmd/mmctl/commands/config.go create mode 100644 server/cmd/mmctl/commands/config_e2e_test.go create mode 100644 server/cmd/mmctl/commands/config_test.go create mode 100644 server/cmd/mmctl/commands/docs.go create mode 100644 server/cmd/mmctl/commands/enterprise.go create mode 100644 server/cmd/mmctl/commands/errors.go create mode 100644 server/cmd/mmctl/commands/export.go create mode 100644 server/cmd/mmctl/commands/export_e2e_test.go create mode 100644 server/cmd/mmctl/commands/export_test.go create mode 100644 server/cmd/mmctl/commands/extract.go create mode 100644 server/cmd/mmctl/commands/extract_e2e_test.go create mode 100644 server/cmd/mmctl/commands/group.go create mode 100644 server/cmd/mmctl/commands/group_e2e_test.go create mode 100644 server/cmd/mmctl/commands/group_test.go create mode 100644 server/cmd/mmctl/commands/import.go create mode 100644 server/cmd/mmctl/commands/import_e2e_test.go create mode 100644 server/cmd/mmctl/commands/import_test.go create mode 100644 server/cmd/mmctl/commands/importer/utils.go create mode 100644 server/cmd/mmctl/commands/importer/validate.go create mode 100644 server/cmd/mmctl/commands/init.go create mode 100644 server/cmd/mmctl/commands/init_test.go create mode 100644 server/cmd/mmctl/commands/integrity.go create mode 100644 server/cmd/mmctl/commands/integrity_test.go create mode 100644 server/cmd/mmctl/commands/ldap.go create mode 100644 server/cmd/mmctl/commands/ldap_e2e_test.go create mode 100644 server/cmd/mmctl/commands/ldap_test.go create mode 100644 server/cmd/mmctl/commands/license.go create mode 100644 server/cmd/mmctl/commands/license_e2e_test.go create mode 100644 server/cmd/mmctl/commands/license_test.go create mode 100644 server/cmd/mmctl/commands/logs.go create mode 100644 server/cmd/mmctl/commands/logs_e2e_test.go create mode 100644 server/cmd/mmctl/commands/logs_test.go create mode 100644 server/cmd/mmctl/commands/main_test.go create mode 100644 server/cmd/mmctl/commands/mmctl_e2e_test.go create mode 100644 server/cmd/mmctl/commands/mmctl_test.go create mode 100644 server/cmd/mmctl/commands/mmctl_unit_test.go create mode 100644 server/cmd/mmctl/commands/permission_role_test.go create mode 100644 server/cmd/mmctl/commands/permissions.go create mode 100644 server/cmd/mmctl/commands/permissions_e2e_test.go create mode 100644 server/cmd/mmctl/commands/permissions_reset_e2e_test.go create mode 100644 server/cmd/mmctl/commands/permissions_role.go create mode 100644 server/cmd/mmctl/commands/permissions_role_e2e_test.go create mode 100644 server/cmd/mmctl/commands/permissions_test.go create mode 100644 server/cmd/mmctl/commands/plugin.go create mode 100644 server/cmd/mmctl/commands/plugin_e2e_test.go create mode 100644 server/cmd/mmctl/commands/plugin_marketplace.go create mode 100644 server/cmd/mmctl/commands/plugin_marketplace_e2e_test.go create mode 100644 server/cmd/mmctl/commands/plugin_marketplace_test.go create mode 100644 server/cmd/mmctl/commands/plugin_test.go create mode 100644 server/cmd/mmctl/commands/post.go create mode 100644 server/cmd/mmctl/commands/post_e2e_test.go create mode 100644 server/cmd/mmctl/commands/post_test.go create mode 100644 server/cmd/mmctl/commands/roles.go create mode 100644 server/cmd/mmctl/commands/roles_test.go create mode 100644 server/cmd/mmctl/commands/root.go create mode 100644 server/cmd/mmctl/commands/saml.go create mode 100644 server/cmd/mmctl/commands/saml_test.go create mode 100644 server/cmd/mmctl/commands/sampledata.go create mode 100644 server/cmd/mmctl/commands/sampledata_test.go create mode 100644 server/cmd/mmctl/commands/sampledata_util.go create mode 100644 server/cmd/mmctl/commands/system.go create mode 100644 server/cmd/mmctl/commands/system_e2e_test.go create mode 100644 server/cmd/mmctl/commands/system_test.go create mode 100644 server/cmd/mmctl/commands/team.go create mode 100644 server/cmd/mmctl/commands/team_e2e_test.go create mode 100644 server/cmd/mmctl/commands/team_test.go create mode 100644 server/cmd/mmctl/commands/team_users.go create mode 100644 server/cmd/mmctl/commands/team_users_e2e_test.go create mode 100644 server/cmd/mmctl/commands/team_users_test.go create mode 100644 server/cmd/mmctl/commands/teamargs.go create mode 100644 server/cmd/mmctl/commands/teamargs_test.go create mode 100644 server/cmd/mmctl/commands/token.go create mode 100644 server/cmd/mmctl/commands/token_e2e_test.go create mode 100644 server/cmd/mmctl/commands/token_test.go create mode 100644 server/cmd/mmctl/commands/user.go create mode 100644 server/cmd/mmctl/commands/user_e2e_test.go create mode 100644 server/cmd/mmctl/commands/user_test.go create mode 100644 server/cmd/mmctl/commands/userargs.go create mode 100644 server/cmd/mmctl/commands/userargs_test.go create mode 100644 server/cmd/mmctl/commands/utils.go create mode 100644 server/cmd/mmctl/commands/utils_unix.go create mode 100644 server/cmd/mmctl/commands/utils_unix_test.go create mode 100644 server/cmd/mmctl/commands/utils_windows.go create mode 100644 server/cmd/mmctl/commands/version.go create mode 100644 server/cmd/mmctl/commands/webhook.go create mode 100644 server/cmd/mmctl/commands/webhook_e2e_test.go create mode 100644 server/cmd/mmctl/commands/webhook_test.go create mode 100644 server/cmd/mmctl/commands/websockets.go create mode 100644 server/cmd/mmctl/docs/mmctl.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_clean.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_current.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_login.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_renew.rst create mode 100644 server/cmd/mmctl/docs/mmctl_auth_set.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_assign.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_disable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_enable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_bot_update.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_archive.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_modify.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_move.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_rename.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_search.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_unarchive.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_users.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_users_add.rst create mode 100644 server/cmd/mmctl/docs/mmctl_channel_users_remove.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_archive.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_modify.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_move.rst create mode 100644 server/cmd/mmctl/docs/mmctl_command_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_completion.rst create mode 100644 server/cmd/mmctl/docs/mmctl_completion_bash.rst create mode 100644 server/cmd/mmctl/docs/mmctl_completion_zsh.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_edit.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_get.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_migrate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_patch.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_reload.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_reset.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_set.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_config_subpath.rst create mode 100644 server/cmd/mmctl/docs/mmctl_docs.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_download.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_job.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_job_cancel.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_job_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_job_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_export_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_extract.rst create mode 100644 server/cmd/mmctl/docs/mmctl_extract_job.rst create mode 100644 server/cmd/mmctl/docs/mmctl_extract_job_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_extract_job_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_extract_run.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_channel.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_channel_disable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_channel_enable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_channel_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_channel_status.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_list-ldap.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_team.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_team_disable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_team_enable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_team_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_team_status.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_user.rst create mode 100644 server/cmd/mmctl/docs/mmctl_group_user_restore.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_job.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_job_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_job_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_list_available.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_list_incomplete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_process.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_upload.rst create mode 100644 server/cmd/mmctl/docs/mmctl_import_validate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_integrity.rst create mode 100644 server/cmd/mmctl/docs/mmctl_ldap.rst create mode 100644 server/cmd/mmctl/docs/mmctl_ldap_idmigrate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_ldap_sync.rst create mode 100644 server/cmd/mmctl/docs/mmctl_license.rst create mode 100644 server/cmd/mmctl/docs/mmctl_license_remove.rst create mode 100644 server/cmd/mmctl/docs/mmctl_license_upload-string.rst create mode 100644 server/cmd/mmctl/docs/mmctl_license_upload.rst create mode 100644 server/cmd/mmctl/docs/mmctl_logs.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_add.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_remove.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_reset.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_role.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_role_assign.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_role_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_permissions_role_unassign.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_add.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_disable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_enable.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_install-url.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_marketplace.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_marketplace_install.rst create mode 100644 server/cmd/mmctl/docs/mmctl_plugin_marketplace_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_post.rst create mode 100644 server/cmd/mmctl/docs/mmctl_post_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_post_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_roles.rst create mode 100644 server/cmd/mmctl/docs/mmctl_roles_member.rst create mode 100644 server/cmd/mmctl/docs/mmctl_roles_system-admin.rst create mode 100644 server/cmd/mmctl/docs/mmctl_saml.rst create mode 100644 server/cmd/mmctl/docs/mmctl_saml_auth-data-reset.rst create mode 100644 server/cmd/mmctl/docs/mmctl_sampledata.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system_clearbusy.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system_getbusy.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system_setbusy.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system_status.rst create mode 100644 server/cmd/mmctl/docs/mmctl_system_version.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_archive.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_modify.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_rename.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_restore.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_search.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_users.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_users_add.rst create mode 100644 server/cmd/mmctl/docs/mmctl_team_users_remove.rst create mode 100644 server/cmd/mmctl/docs/mmctl_token.rst create mode 100644 server/cmd/mmctl/docs/mmctl_token_generate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_token_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_token_revoke.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_activate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_change-password.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_convert.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_create.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_deactivate.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_deleteall.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_demote.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_email.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_invite.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_migrate-auth.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_promote.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_reset-password.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_resetmfa.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_search.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_username.rst create mode 100644 server/cmd/mmctl/docs/mmctl_user_verify.rst create mode 100644 server/cmd/mmctl/docs/mmctl_version.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_create-incoming.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_create-outgoing.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_delete.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_list.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_modify-incoming.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_modify-outgoing.rst create mode 100644 server/cmd/mmctl/docs/mmctl_webhook_show.rst create mode 100644 server/cmd/mmctl/docs/mmctl_websocket.rst create mode 100644 server/cmd/mmctl/mmctl.go create mode 100644 server/cmd/mmctl/mocks/client_mock.go create mode 100644 server/cmd/mmctl/mocks/copyright.txt create mode 100644 server/cmd/mmctl/printer/human/entry.go create mode 100644 server/cmd/mmctl/printer/human/logrus_writer.go create mode 100644 server/cmd/mmctl/printer/human/parser.go create mode 100644 server/cmd/mmctl/printer/human/process.go create mode 100644 server/cmd/mmctl/printer/human/simple_writer.go create mode 100644 server/cmd/mmctl/printer/keys.go create mode 100644 server/cmd/mmctl/printer/printer.go create mode 100644 server/cmd/mmctl/printer/printer_test.go create mode 100644 server/cmd/mmctl/printer/util.go delete mode 100755 server/scripts/download_mmctl_release.sh diff --git a/.github/workflows/mmctl-test-template.yml b/.github/workflows/mmctl-test-template.yml new file mode 100644 index 0000000000..5afcc2fd8b --- /dev/null +++ b/.github/workflows/mmctl-test-template.yml @@ -0,0 +1,50 @@ +name: mmctl CI +on: + workflow_call: + inputs: + datasource: + required: true + type: string + drivername: + required: true + type: string +env: + go-version: "1.19.5" +jobs: + run-mmctl-tests: + runs-on: ubuntu-latest-8-cores + env: + COMPOSE_PROJECT_NAME: ghactions + BUILD_IMAGE: mattermost/mattermost-build-server:20230118_golang-1.19.5 + steps: + - name: Checkout mattermost-server + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + - name: Setup Go + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 + with: + go-version: ${{ env.go-version }} + - name: Run docker compose + run: | + cd server/build + docker-compose --no-ansi run --rm start_dependencies + cat ../tests/test-data.ldif | docker-compose --no-ansi exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; + docker-compose --no-ansi exec -T minio sh -c 'mkdir -p /data/mattermost-test'; + docker-compose --no-ansi ps + cd .. + - name: Wait for docker compose + run: | + until docker network inspect ghactions_mm-test; do echo "Waiting for Docker Compose Network..."; sleep 1; done; + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://mysql:3306; do echo waiting for mysql; sleep 5; done;" + docker run --net ghactions_mm-test appropriate/curl:latest sh -c "until curl --max-time 5 --output - http://elasticsearch:9200; do echo waiting for elasticsearch; sleep 5; done;" + - name: Run mmctl Tests + run: | + mkdir -p client/plugins + cd server/build + docker run --net ghactions_mm-test \ + --ulimit nofile=8096:8096 \ + --env-file=dotenv/test.env \ + --env MM_SQLSETTINGS_DATASOURCE="${{ inputs.datasource }}" \ + -v ~/work/mattermost:/mattermost \ + -w /mattermost/mattermost/server \ + $BUILD_IMAGE \ + make test-mmctl-coverage BUILD_NUMBER=$GITHUB_HEAD_REF-$GITHUB_RUN_ID MM_SERVER_PATH=/mattermost/mattermost/server diff --git a/.github/workflows/server-ci-template.yml b/.github/workflows/server-ci-template.yml index 80c89c7a56..b7a83c926e 100644 --- a/.github/workflows/server-ci-template.yml +++ b/.github/workflows/server-ci-template.yml @@ -226,6 +226,25 @@ jobs: run: make app-layers - name: Check generated code run: if [[ -n $(git status --porcelain) ]]; then echo "Please update the app layers using make app-layers"; exit 1; fi + check-mmctl-docs: + name: Check mmctl docs + runs-on: ubuntu-22.04 + steps: + - name: Checkout mattermost-server + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + - name: Setup Go + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 + with: + go-version: ${{ env.go-version }} + cache-dependency-path: | + server/go.sum + server/public/go.sum + - name: Check docs + run: | + echo "Making sure docs are updated" + cd server + make mmctl-docs + if [[ -n $(git status --porcelain) ]]; then echo "Please update the mmctl docs using make mmctl-docs"; exit 1; fi test-postgres-binary: name: Postgres with binary parameters needs: check-mattermost-vet @@ -247,6 +266,13 @@ jobs: with: datasource: mmuser:mostest@tcp(mysql:3306)/mattermost_test?charset=utf8mb4,utf8&multiStatements=true&maxAllowedPacket=4194304 drivername: mysql + test-mmctl: + name: Run mmctl tests + needs: check-mattermost-vet + uses: ./.github/workflows/mmctl-test-template.yml + with: + datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10 + drivername: postgres build-mattermost-server: name: Build mattermost server app runs-on: ubuntu-latest-8-cores diff --git a/.github/workflows/server-test-template.yml b/.github/workflows/server-test-template.yml index c7171f8561..1382f1b1f6 100644 --- a/.github/workflows/server-test-template.yml +++ b/.github/workflows/server-test-template.yml @@ -54,4 +54,4 @@ jobs: -v ~/work/mattermost:/mattermost \ -w /mattermost/mattermost/server \ $BUILD_IMAGE \ - make test-server$RACE_MODE BUILD_NUMBER=$GITHUB_HEAD_REF-$GITHUB_RUN_ID TESTFLAGS= TESTFLAGSEE= + make test-server$RACE_MODE BUILD_NUMBER=$GITHUB_HEAD_REF-$GITHUB_RUN_ID TESTFLAGS= TESTFLAGSEE= diff --git a/server/.gitignore b/server/.gitignore index ef19ec3cbe..396dd86d20 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -108,6 +108,7 @@ app/data/* cover.out ecover.out +mmctlcover.out cprofile.out *.test webapp/coverage @@ -122,6 +123,7 @@ webapp/coverage /client __debug_bin report.xml +*coverage.txt go.*.orig config.override.mk docker-compose.override.yaml diff --git a/server/Makefile b/server/Makefile index a413efd2c6..c2570f5531 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race new-migration migrations-extract +.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -34,6 +34,13 @@ BUILD_NUMBER ?= $(BUILD_NUMBER:) BUILD_DATE = $(shell date -u) BUILD_HASH = $(shell git rev-parse HEAD) +# treestate +GIT_TREESTATE = clean +DIFF = $(shell git diff --quiet >/dev/null 2>&1; if [ $$? -eq 1 ]; then echo "1"; fi) +ifeq ($(DIFF), 1) + GIT_TREESTATE = dirty +endif + # Go tags GOTAGS ?= $(GOTAGS:) @@ -51,6 +58,15 @@ ifeq ($(BUILD_NUMBER),dev) GOTAGS += "testlicensekey" endif +# mmctl +MM_SERVER_PATH ?= $(PWD) +MMCTL_BUILD_TAGS = +MMCTL_TESTFLAGS = -timeout 30m -race -v +MMCTL_PKG = github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/commands +LDFLAGS += -X "$(MMCTL_PKG).gitCommit=$(BUILD_HASH)" +LDFLAGS += -X "$(MMCTL_PKG).gitTreeState=$(GIT_TREESTATE)" +LDFLAGS += -X "$(MMCTL_PKG).buildDate=$(BUILD_DATE)" + # Enterprise BUILD_ENTERPRISE_DIR ?= ../../enterprise BUILD_ENTERPRISE ?= true @@ -58,6 +74,9 @@ BUILD_ENTERPRISE_READY = false BUILD_TYPE_NAME = team BUILD_HASH_ENTERPRISE = none ifneq ($(wildcard $(BUILD_ENTERPRISE_DIR)/.),) + MMCTL_TESTFLAGS += -ldflags '-X "$(MMCTL_PKG).EnableEnterpriseTests=true" -X "github.com/mattermost/mattermost-server/server/public/model.BuildEnterpriseReady=true"' + MMCTL_BUILD_TAGS += enterprise + ifeq ($(BUILD_ENTERPRISE),true) BUILD_ENTERPRISE_READY = true BUILD_TYPE_NAME = enterprise @@ -132,10 +151,11 @@ DIST_PATH_WIN=$(DIST_ROOT)/windows/mattermost TESTS=. # Packages lists -TE_PACKAGES=$(shell $(GO) list ./... | grep -vE 'server/v8/playbooks|server/v8/boards') +TE_PACKAGES=$(shell $(GO) list ./... | grep -vE 'server/v8/playbooks|server/v8/boards|server/v8/cmd/mmctl') BOARDS_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/boards') PLAYBOOKS_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/playbooks') -SUITE_PACKAGES=$(shell $(GO) list ./...) +SUITE_PACKAGES=$(shell $(GO) list ./...| grep -vE 'server/v8/cmd/mmctl') +MMCTL_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/cmd/mmctl') TEMPLATES_DIR=templates @@ -288,9 +308,9 @@ prepackaged-plugins: ## Populate the prepackaged-plugins directory prepackaged-binaries: ## Populate the prepackaged-binaries to the bin directory ifeq ($(shell test -f bin/mmctl && printf "yes"),yes) - @echo "MMCTL already exists in bin/mmctl not downloading a new version." + @echo "MMCTL already exists in bin/mmctl, not compiling." else - @scripts/download_mmctl_release.sh + $(MAKE) mmctl-build endif golangci-lint: ## Run golangci-lint on codebase @@ -385,10 +405,14 @@ platform-mocks: ## Creates mocks for platform interfaces. $(GO) install github.com/vektra/mockery/v2/...@v2.14.0 $(GOBIN)/mockery --dir channels/app/platform --name SuiteIFace --output channels/app/platform/mocks --note 'Regenerate this file using `make platform-mocks`.' +mmctl-mocks: ## Creates mocks for mmctl + $(GO) install github.com/golang/mock/mockgen@v1.6.0 + $(GOBIN)/mockgen -destination=cmd/mmctl/mocks/client_mock.go -copyright_file=cmd/mmctl/mocks/copyright.txt -package=mocks github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client Client + pluginapi: ## Generates api and hooks glue code for plugins $(GO) generate $(GOFLAGS) ./public/plugin -mocks: store-mocks telemetry-mocks filestore-mocks ldap-mocks plugin-mocks einterfaces-mocks searchengine-mocks sharedchannel-mocks misc-mocks email-mocks platform-mocks +mocks: store-mocks telemetry-mocks filestore-mocks ldap-mocks plugin-mocks einterfaces-mocks searchengine-mocks sharedchannel-mocks misc-mocks email-mocks platform-mocks mmctl-mocks layers: app-layers store-layers pluginapi boards-gen @@ -522,6 +546,23 @@ inject-test-data: # add test data to the local instance. @echo Login with a regular account username=user-1 password=SampleUs@r-1 @echo ======================================================================== +test-mmctl-unit: + @echo Running mmctl unit tests + $(GO) test $(MMCTL_TESTFLAGS) -tags 'unit $(MMCTL_BUILD_TAGS)' $(MMCTL_PACKAGES) + +test-mmctl-e2e: start-docker + @echo Running mmctl e2e tests + MM_SERVER_PATH=$(MM_SERVER_PATH) $(GO) test $(MMCTL_TESTFLAGS) -tags 'e2e $(MMCTL_BUILD_TAGS)' $(MMCTL_PACKAGES) + +test-mmctl: start-docker + @echo Running all mmctl tests + MM_SERVER_PATH=$(MM_SERVER_PATH) $(GO) test $(MMCTL_TESTFLAGS) -tags 'unit e2e $(MMCTL_BUILD_TAGS)' $(MMCTL_PACKAGES) + +test-mmctl-coverage: start-docker + @echo Running all mmctl tests with coverage + MM_SERVER_PATH=$(MM_SERVER_PATH) $(GO) test $(MMCTL_TESTFLAGS) -tags 'unit e2e $(MMCTL_BUILD_TAGS)' -coverprofile=mmctlcover.out $(MMCTL_PACKAGES) + $(GO) tool cover -html=mmctlcover.out + validate-go-version: ## Validates the installed version of go against Mattermost's minimum requirement. @if [ $(GO_MAJOR_VERSION) -gt $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION) ]; then \ exit 0 ;\ @@ -745,6 +786,13 @@ ifeq ($(BUILD_ENTERPRISE_READY),true) @! ag --ignore Makefile --ignore-dir runtime '(TODO|XXX|FIXME|"FIX ME")[: ]+' $(BUILD_ENTERPRISE_DIR)/ endif +mmctl-build: ## Compiles and generates the mmctl binary + go build -trimpath -ldflags '$(LDFLAGS)' -o $(GOBIN) ./cmd/mmctl + +mmctl-docs: ## Generate the mmctl docs + rm -rf ./cmd/mmctl/docs + cd ./cmd/mmctl && go run mmctl.go docs + ## Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' ./Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/server/build/release.mk b/server/build/release.mk index fd5d7dc4e9..60004e5d0d 100644 --- a/server/build/release.mk +++ b/server/build/release.mk @@ -156,16 +156,13 @@ package-general: mkdir -p $(DIST_PATH_GENERIC)/logs mkdir -p $(DIST_PATH_GENERIC)/prepackaged_plugins - @# Copy binary + @# Copy binaries ifeq ($(BUILDER_GOOS_GOARCH),"$(CURRENT_PACKAGE_ARCH)") - cp $(GOBIN)/$(MM_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from native bin dir, not cross-compiled + cp $(GOBIN)/$(MM_BIN_NAME) $(GOBIN)/$(MMCTL_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from native bin dir, not cross-compiled else - cp $(GOBIN)/$(CURRENT_PACKAGE_ARCH)/$(MM_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from cross-compiled bin dir + cp $(GOBIN)/$(CURRENT_PACKAGE_ARCH)/$(MM_BIN_NAME) $(GOBIN)/$(CURRENT_PACKAGE_ARCH)/$(MMCTL_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from cross-compiled bin dir endif - #Download MMCTL for $(MMCTL_PLATFORM) - scripts/download_mmctl_release.sh $(MMCTL_PLATFORM) $(DIST_PATH_GENERIC)/bin - ifeq ("darwin_arm64","$(CURRENT_PACKAGE_ARCH)") echo "No plugins yet for $(CURRENT_PACKAGE_ARCH) platform, skipping..." else ifeq ("linux_arm64","$(CURRENT_PACKAGE_ARCH)") @@ -195,14 +192,14 @@ else endif package-osx-amd64: package-prep - DIST_PATH_GENERIC=$(DIST_PATH_OSX_AMD64) CURRENT_PACKAGE_ARCH=darwin_amd64 PLUGIN_ARCH=osx-amd64 MMCTL_PLATFORM="Darwin-x86_64" MM_BIN_NAME=mattermost $(MAKE) package-general + DIST_PATH_GENERIC=$(DIST_PATH_OSX_AMD64) CURRENT_PACKAGE_ARCH=darwin_amd64 PLUGIN_ARCH=osx-amd64 MMCTL_PLATFORM="Darwin-x86_64" MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general @# Package tar -C $(DIST_PATH_OSX_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-osx-amd64.tar.gz mattermost ../mattermost @# Cleanup rm -rf $(DIST_ROOT)/osx_amd64 package-osx-arm64: package-prep - DIST_PATH_GENERIC=$(DIST_PATH_OSX_ARM64) CURRENT_PACKAGE_ARCH=darwin_arm64 PLUGIN_ARCH=osx-arm64 MMCTL_PLATFORM="Darwin-arm64" MM_BIN_NAME=mattermost $(MAKE) package-general + DIST_PATH_GENERIC=$(DIST_PATH_OSX_ARM64) CURRENT_PACKAGE_ARCH=darwin_arm64 PLUGIN_ARCH=osx-arm64 MMCTL_PLATFORM="Darwin-arm64" MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general @# Package tar -C $(DIST_PATH_OSX_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-osx-arm64.tar.gz mattermost ../mattermost @# Cleanup @@ -211,14 +208,14 @@ package-osx-arm64: package-prep package-osx: package-osx-amd64 package-osx-arm64 package-linux-amd64: package-prep - DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) CURRENT_PACKAGE_ARCH=linux_amd64 PLUGIN_ARCH=linux-amd64 MMCTL_PLATFORM="Linux-x86_64" MM_BIN_NAME=mattermost $(MAKE) package-general + DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) CURRENT_PACKAGE_ARCH=linux_amd64 PLUGIN_ARCH=linux-amd64 MMCTL_PLATFORM="Linux-x86_64" MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general @# Package tar -C $(DIST_PATH_LIN_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-linux-amd64.tar.gz mattermost ../mattermost @# Cleanup rm -rf $(DIST_ROOT)/linux_amd64 package-linux-arm64: package-prep - DIST_PATH_GENERIC=$(DIST_PATH_LIN_ARM64) CURRENT_PACKAGE_ARCH=linux_arm64 PLUGIN_ARCH=linux-arm64 MMCTL_PLATFORM="Linux-aarch64" MM_BIN_NAME=mattermost $(MAKE) package-general + DIST_PATH_GENERIC=$(DIST_PATH_LIN_ARM64) CURRENT_PACKAGE_ARCH=linux_arm64 PLUGIN_ARCH=linux-arm64 MMCTL_PLATFORM="Linux-aarch64" MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general @# Package tar -C $(DIST_PATH_LIN_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-linux-arm64.tar.gz mattermost ../mattermost @# Cleanup @@ -234,12 +231,10 @@ package-windows: package-prep @# Copy binary ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64") - cp $(GOBIN)/mattermost.exe $(DIST_PATH_WIN)/bin # from native bin dir, not cross-compiled + cp $(GOBIN)/mattermost.exe $(GOBIN)/mmctl.exe $(DIST_PATH_WIN)/bin # from native bin dir, not cross-compiled else - cp $(GOBIN)/windows_amd64/mattermost.exe $(DIST_PATH_WIN)/bin # from cross-compiled bin dir + cp $(GOBIN)/windows_amd64/mattermost.exe $(GOBIN)/windows_amd64/mmctl.exe $(DIST_PATH_WIN)/bin # from cross-compiled bin dir endif - #Download MMCTL for Windows - scripts/download_mmctl_release.sh "Windows" $(DIST_PATH_WIN)/bin @# Prepackage plugins @for plugin_package in $(PLUGIN_PACKAGES) ; do \ ARCH="windows-amd64"; \ diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go new file mode 100644 index 0000000000..ae2535731e --- /dev/null +++ b/server/cmd/mmctl/client/client.go @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package client + +import ( + "io" + "net/http" + + "github.com/mattermost/mattermost-server/server/public/model" +) + +type Client interface { + CreateChannel(channel *model.Channel) (*model.Channel, *model.Response, error) + RemoveUserFromChannel(channelID, userID string) (*model.Response, error) + GetChannelMembers(channelID string, page, perPage int, etag string) (model.ChannelMembers, *model.Response, error) + AddChannelMember(channelID, userID string) (*model.ChannelMember, *model.Response, error) + DeleteChannel(channelID string) (*model.Response, error) + PermanentDeleteChannel(channelID string) (*model.Response, error) + MoveChannel(channelID, teamID string, force bool) (*model.Channel, *model.Response, error) + GetPublicChannelsForTeam(teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error) + GetDeletedChannelsForTeam(teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error) + GetPrivateChannelsForTeam(teamID string, page int, perPage int, etag string) ([]*model.Channel, *model.Response, error) + GetChannelsForTeamForUser(teamID, userID string, includeDeleted bool, etag string) ([]*model.Channel, *model.Response, error) + RestoreChannel(channelID string) (*model.Channel, *model.Response, error) + PatchChannel(channelID string, patch *model.ChannelPatch) (*model.Channel, *model.Response, error) + GetChannelByName(channelName, teamID string, etag string) (*model.Channel, *model.Response, error) + GetChannelByNameIncludeDeleted(channelName, teamID string, etag string) (*model.Channel, *model.Response, error) + GetChannel(channelID, etag string) (*model.Channel, *model.Response, error) + GetTeam(teamID, etag string) (*model.Team, *model.Response, error) + GetTeamByName(name, etag string) (*model.Team, *model.Response, error) + GetAllTeams(etag string, page int, perPage int) ([]*model.Team, *model.Response, error) + CreateTeam(team *model.Team) (*model.Team, *model.Response, error) + PatchTeam(teamID string, patch *model.TeamPatch) (*model.Team, *model.Response, error) + AddTeamMember(teamID, userID string) (*model.TeamMember, *model.Response, error) + RemoveTeamMember(teamID, userID string) (*model.Response, error) + SoftDeleteTeam(teamID string) (*model.Response, error) + PermanentDeleteTeam(teamID string) (*model.Response, error) + RestoreTeam(teamID string) (*model.Team, *model.Response, error) + UpdateTeamPrivacy(teamID string, privacy string) (*model.Team, *model.Response, error) + SearchTeams(search *model.TeamSearch) ([]*model.Team, *model.Response, error) + GetPost(postID string, etag string) (*model.Post, *model.Response, error) + CreatePost(post *model.Post) (*model.Post, *model.Response, error) + GetPostsForChannel(channelID string, page, perPage int, etag string, collapsedThreads bool, includeDeleted bool) (*model.PostList, *model.Response, error) + GetPostsSince(channelID string, since int64, collapsedThreads bool) (*model.PostList, *model.Response, error) + DoAPIPost(url string, data string) (*http.Response, error) + GetLdapGroups() ([]*model.Group, *model.Response, error) + GetGroupsByChannel(channelID string, groupOpts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error) + GetGroupsByTeam(teamID string, groupOpts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error) + RestoreGroup(groupID string, etag string) (*model.Group, *model.Response, error) + UploadLicenseFile(data []byte) (*model.Response, error) + RemoveLicenseFile() (*model.Response, error) + GetLogs(page, perPage int) ([]string, *model.Response, error) + GetRoleByName(name string) (*model.Role, *model.Response, error) + PatchRole(roleID string, patch *model.RolePatch) (*model.Role, *model.Response, error) + UploadPlugin(file io.Reader) (*model.Manifest, *model.Response, error) + UploadPluginForced(file io.Reader) (*model.Manifest, *model.Response, error) + RemovePlugin(id string) (*model.Response, error) + EnablePlugin(id string) (*model.Response, error) + DisablePlugin(id string) (*model.Response, error) + GetPlugins() (*model.PluginsResponse, *model.Response, error) + GetUser(userID, etag string) (*model.User, *model.Response, error) + GetUserByUsername(userName, etag string) (*model.User, *model.Response, error) + GetUserByEmail(email, etag string) (*model.User, *model.Response, error) + PermanentDeleteUser(userID string) (*model.Response, error) + PermanentDeleteAllUsers() (*model.Response, error) + CreateUser(user *model.User) (*model.User, *model.Response, error) + VerifyUserEmailWithoutToken(userID string) (*model.User, *model.Response, error) + UpdateUserRoles(userID, roles string) (*model.Response, error) + InviteUsersToTeam(teamID string, userEmails []string) (*model.Response, error) + SendPasswordResetEmail(email string) (*model.Response, error) + UpdateUser(user *model.User) (*model.User, *model.Response, error) + UpdateUserMfa(userID, code string, activate bool) (*model.Response, error) + UpdateUserPassword(userID, currentPassword, newPassword string) (*model.Response, error) + UpdateUserHashedPassword(userID, newHashedPassword string) (*model.Response, error) + CreateUserAccessToken(userID, description string) (*model.UserAccessToken, *model.Response, error) + RevokeUserAccessToken(tokenID string) (*model.Response, error) + GetUserAccessTokensForUser(userID string, page, perPage int) ([]*model.UserAccessToken, *model.Response, error) + ConvertUserToBot(userID string) (*model.Bot, *model.Response, error) + ConvertBotToUser(userID string, userPatch *model.UserPatch, setSystemAdmin bool) (*model.User, *model.Response, error) + PromoteGuestToUser(userID string) (*model.Response, error) + DemoteUserToGuest(guestID string) (*model.Response, error) + CreateCommand(cmd *model.Command) (*model.Command, *model.Response, error) + ListCommands(teamID string, customOnly bool) ([]*model.Command, *model.Response, error) + GetCommandById(cmdID string) (*model.Command, *model.Response, error) + UpdateCommand(cmd *model.Command) (*model.Command, *model.Response, error) + MoveCommand(teamID string, commandID string) (*model.Response, error) + DeleteCommand(commandID string) (*model.Response, error) + GetConfig() (*model.Config, *model.Response, error) + UpdateConfig(*model.Config) (*model.Config, *model.Response, error) + PatchConfig(*model.Config) (*model.Config, *model.Response, error) + ReloadConfig() (*model.Response, error) + MigrateConfig(from, to string) (*model.Response, error) + SyncLdap(includeRemovedMembers bool) (*model.Response, error) + MigrateIdLdap(toAttribute string) (*model.Response, error) + GetUsers(page, perPage int, etag string) ([]*model.User, *model.Response, error) + GetUsersByIds(userIDs []string) ([]*model.User, *model.Response, error) + GetUsersInTeam(teamID string, page, perPage int, etag string) ([]*model.User, *model.Response, error) + UpdateUserActive(userID string, activate bool) (*model.Response, error) + UpdateTeam(team *model.Team) (*model.Team, *model.Response, error) + UpdateChannelPrivacy(channelID string, privacy model.ChannelType) (*model.Channel, *model.Response, error) + CreateBot(bot *model.Bot) (*model.Bot, *model.Response, error) + PatchBot(userID string, patch *model.BotPatch) (*model.Bot, *model.Response, error) + GetBots(page, perPage int, etag string) ([]*model.Bot, *model.Response, error) + GetBotsIncludeDeleted(page, perPage int, etag string) ([]*model.Bot, *model.Response, error) + GetBotsOrphaned(page, perPage int, etag string) ([]*model.Bot, *model.Response, error) + DisableBot(botUserID string) (*model.Bot, *model.Response, error) + EnableBot(botUserID string) (*model.Bot, *model.Response, error) + AssignBot(botUserID, newOwnerID string) (*model.Bot, *model.Response, error) + SetServerBusy(secs int) (*model.Response, error) + ClearServerBusy() (*model.Response, error) + GetServerBusy() (*model.ServerBusyState, *model.Response, error) + CheckIntegrity() ([]model.IntegrityCheckResult, *model.Response, error) + InstallPluginFromURL(string, bool) (*model.Manifest, *model.Response, error) + InstallMarketplacePlugin(*model.InstallMarketplacePluginRequest) (*model.Manifest, *model.Response, error) + GetMarketplacePlugins(*model.MarketplacePluginFilter) ([]*model.MarketplacePlugin, *model.Response, error) + MigrateAuthToLdap(fromAuthService string, matchField string, force bool) (*model.Response, error) + MigrateAuthToSaml(fromAuthService string, usersMap map[string]string, auto bool) (*model.Response, error) + GetPing() (string, *model.Response, error) + GetPingWithFullServerStatus() (map[string]string, *model.Response, error) + CreateUpload(us *model.UploadSession) (*model.UploadSession, *model.Response, error) + GetUpload(uploadID string) (*model.UploadSession, *model.Response, error) + GetUploadsForUser(userID string) ([]*model.UploadSession, *model.Response, error) + UploadData(uploadID string, data io.Reader) (*model.FileInfo, *model.Response, error) + ListImports() ([]string, *model.Response, error) + GetJob(id string) (*model.Job, *model.Response, error) + GetJobs(page int, perPage int) ([]*model.Job, *model.Response, error) + GetJobsByType(jobType string, page int, perPage int) ([]*model.Job, *model.Response, error) + CreateJob(job *model.Job) (*model.Job, *model.Response, error) + CancelJob(jobID string) (*model.Response, error) + CreateIncomingWebhook(hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error) + UpdateIncomingWebhook(hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error) + GetIncomingWebhooks(page int, perPage int, etag string) ([]*model.IncomingWebhook, *model.Response, error) + GetIncomingWebhooksForTeam(teamID string, page int, perPage int, etag string) ([]*model.IncomingWebhook, *model.Response, error) + GetIncomingWebhook(hookID string, etag string) (*model.IncomingWebhook, *model.Response, error) + DeleteIncomingWebhook(hookID string) (*model.Response, error) + CreateOutgoingWebhook(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error) + UpdateOutgoingWebhook(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error) + GetOutgoingWebhooks(page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error) + GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.Response, error) + GetOutgoingWebhooksForChannel(channelID string, page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error) + GetOutgoingWebhooksForTeam(teamID string, page int, perPage int, etag string) ([]*model.OutgoingWebhook, *model.Response, error) + RegenOutgoingHookToken(hookID string) (*model.OutgoingWebhook, *model.Response, error) + DeleteOutgoingWebhook(hookID string) (*model.Response, error) + ListExports() ([]string, *model.Response, error) + DeleteExport(name string) (*model.Response, error) + DownloadExport(name string, wr io.Writer, offset int64) (int64, *model.Response, error) + ResetSamlAuthDataToEmail(includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error) +} diff --git a/server/cmd/mmctl/commands/auth.go b/server/cmd/mmctl/commands/auth.go new file mode 100644 index 0000000000..88d938c6f5 --- /dev/null +++ b/server/cmd/mmctl/commands/auth.go @@ -0,0 +1,428 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "bufio" + "fmt" + "os" + "sort" + "strings" + "syscall" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/term" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/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") + + url := strings.TrimRight(args[0], "/") + method := MethodPassword + + 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(username, password, mfaToken, url, allowInsecureSHA1, allowInsecureTLS) + method = MethodMFA + } else { + c, _, err = InitClientWithUsernameAndPassword(username, password, url, 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: url, + AuthToken: accessToken, + } + if _, _, err := InitClientWithCredentials(&credentials, allowInsecureSHA1, allowInsecureTLS); err != nil { + return fmt.Errorf("could not initiate client: %w", err) + } + } + + credentials := Credentials{ + Name: name, + InstanceURL: url, + 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, url)) + 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{} + var maxNameLen, maxUsernameLen, maxInstanceURLLen int + 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) + } + } + sort.Slice(serverNames, func(i, j int) bool { + return serverNames[i] < serverNames[j] + }) + + printer.Print(fmt.Sprintf("\n | Active | %*s | %*s | %*s |", maxNameLen, "Name", maxUsernameLen, "Username", maxInstanceURLLen, "InstanceURL")) + 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 + } + + 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(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(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(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 +} diff --git a/server/cmd/mmctl/commands/auth_e2e_test.go b/server/cmd/mmctl/commands/auth_e2e_test.go new file mode 100644 index 0000000000..c6aefeb8d2 --- /dev/null +++ b/server/cmd/mmctl/commands/auth_e2e_test.go @@ -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/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestAuthLoginWithTrailingSlashInInstanceURL() { + s.SetupTestHelper().InitBasic() + + 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 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) + }) +} diff --git a/server/cmd/mmctl/commands/auth_utils.go b/server/cmd/mmctl/commands/auth_utils.go new file mode 100644 index 0000000000..75a0da159d --- /dev/null +++ b/server/cmd/mmctl/commands/auth_utils.go @@ -0,0 +1,231 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "io/ioutil" + "os" + "os/user" + "path/filepath" + "strings" + "sync" + + "github.com/pkg/errors" + "github.com/spf13/viper" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +const ( + MethodPassword = "P" + MethodToken = "T" + MethodMFA = "M" + + userHomeVar = "$HOME" + configFileName = "config" + configParent = "mmctl" + xdgConfigHomeVar = "$XDG_CONFIG_HOME" +) + +var once sync.Once + +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 resolveLegacyConfigFilePath() string { + configPath := viper.GetString("config-path") + // We use the .mmctl file name (hidden) if the config is in $HOME directory. + // If we were using other directory (e.g. XDG_CONFIG_HOME) we go with mmctl file name. + switch configPath { + case "$HOME": + res := strings.Replace(configPath, userHomeVar, currentUser.HomeDir, 1) + return filepath.Join(res, ".mmctl") + case currentUser.HomeDir: + return filepath.Join(configPath, ".mmctl") + default: + return filepath.Join(configPath, "mmctl") + } +} + +func resolveConfigFilePath() string { + // we warn users that config-path is deprecated + suppressWarnings := viper.GetBool("suppress-warnings") + + if viper.IsSet("config-path") { + if !suppressWarnings { + once.Do(func() { + printer.PrintWarning("Since mmctl v6 we have been deprecated the --config-path and started to use --config flag instead.\n" + + "Please use --config flag to set config file. (note that --config-path was pointing to a directory)\n\n" + + "After moving your config file to new directory, please unset the --config-path flag or MMCTL_CONFIG_PATH environment variable.\n") + }) + } + + return resolveLegacyConfigFilePath() + } + + // 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 := ioutil.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 := ioutil.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 := ioutil.ReadFile(file) + if err != nil { + return err + } + *secret = strings.TrimSpace(string(b)) + } + + return nil +} diff --git a/server/cmd/mmctl/commands/auth_utils_test.go b/server/cmd/mmctl/commands/auth_utils_test.go new file mode 100644 index 0000000000..03f29b6ca9 --- /dev/null +++ b/server/cmd/mmctl/commands/auth_utils_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + 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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + testUser.HomeDir = tmp + SetUser(testUser) + + expected := filepath.Join(testUser.HomeDir, ".config", configParent, configFileName) + + _ = os.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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + + testUser.HomeDir = "path/should/be/ignored" + SetUser(testUser) + + expected := filepath.Join(tmp, configFileName) + + err := os.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored") + require.NoError(t, err) + 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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + + testUser.HomeDir = "path/should/be/ignored" + SetUser(testUser) + + expected := filepath.Join(testUser.HomeDir, "/.config/mmctl/config") + + err := os.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored") + require.NoError(t, err) + 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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + + testUser.HomeDir = "path/should/be/ignored" + SetUser(testUser) + extraDir := "extra" + + expected := filepath.Join(tmp, extraDir, "config.json") + + err := os.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored") + require.NoError(t, err) + 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, _ := ioutil.TempDir("", "mmctl-") + defer os.RemoveAll(tmp) + + testUser.HomeDir = "path/should/be/ignored" + SetUser(testUser) + + err := os.Setenv("XDG_CONFIG_HOME", "path/should/be/ignored") + require.NoError(t, err) + 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 := ioutil.TempFile(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) + }) +} diff --git a/server/cmd/mmctl/commands/bot.go b/server/cmd/mmctl/commands/bot.go new file mode 100644 index 0000000000..fbda862b75 --- /dev/null +++ b/server/cmd/mmctl/commands/bot.go @@ -0,0 +1,284 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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(&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(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 := 200 + tpl := `{{.UserId}}: {{.Username}}` + for { + var bots []*model.Bot + var err error + if all { //nolint:gocritic + bots, _, err = c.GetBotsIncludeDeleted(page, perPage, "") + } else if orphaned { + bots, _, err = c.GetBotsOrphaned(page, perPage, "") + } else { + bots, _, err = c.GetBots(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(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) < 200 { + 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(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(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(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 +} diff --git a/server/cmd/mmctl/commands/bot_e2e_test.go b/server/cmd/mmctl/commands/bot_e2e_test.go new file mode 100644 index 0000000000..fedb417ac5 --- /dev/null +++ b/server/cmd/mmctl/commands/bot_e2e_test.go @@ -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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestListBotCmdF() { + s.SetupTestHelper().InitBasic().DeleteBots() + + s.RunForSystemAdminAndLocal("List Bot", func(c client.Client) { + printer.Clean() + + bot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewId(), OwnerId: s.th.BasicUser.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(bot.UserId) + s.Require().Nil(err) + }() + + deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewId(), OwnerId: s.th.BasicUser.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(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.NewId(), 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.NewId(), OwnerId: s.th.BasicUser.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(bot.UserId) + s.Require().Nil(err) + }() + + deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewId(), OwnerId: user.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(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.NewId(), OwnerId: user.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(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.NewId(), 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.NewId(), OwnerId: s.th.BasicUser2.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(bot.UserId) + s.Require().Nil(err) + }() + + orphanBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewId(), OwnerId: user.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(orphanBot.UserId) + s.Require().Nil(err) + }() + + deletedBot, appErr := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: model.NewId(), OwnerId: s.th.BasicUser2.Id}) + s.Require().Nil(appErr) + defer func() { + err := s.th.App.PermanentDeleteBot(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.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.NewId(), 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.NewId(), 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(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.NewId(), 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.NewId(), 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(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.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.NewId(), 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.NewId(), 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(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.NewId(), 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.NewId(), 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(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.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.NewId(), 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.NewId(), 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.NewId(), OwnerId: botOwner.Id}) + s.Require().Nil(appErr) + s.Require().Equal(bot.OwnerId, botOwner.Id) + defer func() { + err := s.th.App.PermanentDeleteBot(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.NewId(), 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.NewId(), 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.NewId(), OwnerId: botOwner.Id}) + s.Require().Nil(appErr) + s.Require().Equal(bot.OwnerId, botOwner.Id) + defer func() { + err := s.th.App.PermanentDeleteBot(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() + + 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(bot.UserId) + s.Require().Nil(err) + }() + token, ok := printer.GetLines()[1].(*model.UserAccessToken) + s.Require().True(ok) + defer func() { + err := s.th.App.RevokeUserAccessToken(token) + s.Require().Nil(err) + }() + s.Require().Empty(printer.GetErrorLines()) + }) +} diff --git a/server/cmd/mmctl/commands/bot_test.go b/server/cmd/mmctl/commands/bot_test.go new file mode 100644 index 0000000000..81d4819906 --- /dev/null +++ b/server/cmd/mmctl/commands/bot_test.go @@ -0,0 +1,771 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + + gomock "github.com/golang/mock/gomock" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/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(&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(&mockBot). + Return(&mockBot, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(model.UserFromBot(&mockBot), &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateUserAccessToken(mockBot.UserId, "autogenerated"). + 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(&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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchBot(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchBot(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(0, 200, ""). + Return([]*model.Bot{&mockBot}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersByIds([]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(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(0, 200, ""). + Return([]*model.Bot{&mockBot}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersByIds([]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(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(0, 200, ""). + Return([]*model.Bot{&mockBot}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersByIds([]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(0, 200, ""). + Return([]*model.Bot{&mockBot}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersByIds([]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(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(0, 200, ""). + Return([]*model.Bot{&mockBot}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersByIds([]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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DisableBot(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DisableBot(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + EnableBot(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + EnableBot(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockBotUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(&mockNewOwner, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + AssignBot(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(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockBotUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(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(). + GetUserByEmail(botArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(botArg, ""). + Return(&mockBotUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(&mockNewOwner, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + AssignBot(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'") + }) +} diff --git a/server/cmd/mmctl/commands/channel.go b/server/cmd/mmctl/commands/channel.go new file mode 100644 index 0000000000..877c75a84a --- /dev/null +++ b/server/cmd/mmctl/commands/channel.go @@ -0,0 +1,629 @@ +// 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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/web" + + "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 RemoveChannelUsersCmd = &cobra.Command{ + Use: "remove [channel] [users]", + Short: "Remove users from channel", + Long: "Remove some users from channel", + Example: ` channel remove myteam:mychannel user@example.com username + channel remove myteam:mychannel --all-users`, + Deprecated: "please use \"users remove\" instead", + RunE: withClient(channelUsersRemoveCmdF), +} + +var AddChannelUsersCmd = &cobra.Command{ + Use: "add [channel] [users]", + Short: "Add users to channel", + Long: "Add some users to channel", + Example: " channel add myteam:mychannel user@example.com username", + Deprecated: "please use \"users add\" instead", + RunE: withClient(channelUsersAddCmdF), +} + +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 RestoreChannelsCmd = &cobra.Command{ + Use: "restore [channels]", + Deprecated: "please use \"unarchive\" instead", + Short: "Restore some channels", + Long: `Restore a previously deleted channel +Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`, + Example: " channel restore myteam:mychannel", + RunE: withClient(unarchiveChannelsCmdF), +} + +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 MakeChannelPrivateCmd = &cobra.Command{ + Use: "make-private [channel]", + Aliases: []string{"make_private"}, + Short: "Set a channel's type to private", + Long: `Set the type of a channel from Public to Private. +Channel can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID.`, + Example: " channel make-private myteam:mychannel", + Deprecated: "please use \"channel modify --private\" instead", + RunE: withClient(makeChannelPrivateCmdF), +} + +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. 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("display_name", "", "") + _ = ChannelCreateCmd.Flags().MarkDeprecated("display_name", "please use display-name instead") + 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") + ChannelRenameCmd.Flags().String("display_name", "", "") + _ = ChannelRenameCmd.Flags().MarkDeprecated("display_name", "please use display-name instead") + + RemoveChannelUsersCmd.Flags().Bool("all-users", false, "Remove all users from the indicated channel.") + + 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.") + + DeleteChannelsCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the channel and a DB backup has been performed.") + + ChannelCmd.AddCommand( + ChannelCreateCmd, + RemoveChannelUsersCmd, + AddChannelUsersCmd, + ArchiveChannelsCmd, + ListChannelsCmd, + RestoreChannelsCmd, + UnarchiveChannelCmd, + MakeChannelPrivateCmd, + 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 == "" { + 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(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(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(teamID, page, web.PerPageMaximum, "") + 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(teamID, page, web.PerPageMaximum, "") + 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) + + 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("{{.Name}}", 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("{{.Name}} (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("{{.Name}} (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") + } + + channels := getChannelsFromChannelArgs(c, args) + for i, channel := range channels { + if channel == nil { + printer.PrintError("Unable to find channel '" + args[i] + "'") + continue + } + if _, _, err := c.RestoreChannel(channel.Id); err != nil { + printer.PrintError("Unable to unarchive channel '" + args[i] + "'. Error: " + err.Error()) + } + } + + return nil +} + +func makeChannelPrivateCmdF(c client.Client, cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("enter one channel to modify") + } + + channel := getChannelFromChannelArg(c, args[0]) + if channel == nil { + return errors.Errorf("unable to find channel %q", args[0]) + } + + if !(channel.Type == model.ChannelTypeOpen) { + return errors.New("you can only change the type of public channels") + } + + if _, _, err := c.UpdateChannelPrivacy(channel.Id, model.ChannelTypePrivate); err != nil { + return err + } + + return nil +} + +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(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 || newDisplayName == "" { + 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(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(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 := c.GetAllTeams("", 0, 9999) + if err != nil { + return err + } + + for _, team := range teams { + channel, _, _ = c.GetChannelByName(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") + + 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 + } + + newChannel, _, err := c.MoveChannel(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() +} + +func getPrivateChannels(c client.Client, teamID string) ([]*model.Channel, error) { + allPrivateChannels := []*model.Channel{} + page := 0 + withoutError := true + + for { + channelsPage, _, err := c.GetPrivateChannelsForTeam(teamID, page, web.PerPageMaximum, "") + 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(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(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() +} diff --git a/server/cmd/mmctl/commands/channel_e2e_test.go b/server/cmd/mmctl/commands/channel_e2e_test.go new file mode 100644 index 0000000000..714cf1469d --- /dev/null +++ b/server/cmd/mmctl/commands/channel_e2e_test.go @@ -0,0 +1,621 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "sort" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/mattermost/mattermost-server/server/v8/channels/app" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestListChannelsCmdF() { + s.SetupTestHelper().InitBasic() + + var assertChannelNames = func(want []string, lines []interface{}) { + var got []string + for i := 0; i < len(lines); i++ { + got = append(got, lines[i].(*model.Channel).Name) + } + + sort.Strings(want) + sort.Strings(got) + + s.Equal(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.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.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.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.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)}) + s.Require().Nil(err) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to unarchive channel '%s:%s'", s.th.BasicTeam.Id, s.th.BasicDeletedChannel.Name)) + 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")}) + s.Require().Nil(err) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to find channel '%s:%s'", s.th.BasicTeam.Id, "nonexistent-channel")) + }) + + 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)}) + s.Require().Nil(err) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to unarchive channel '%s:%s'", s.th.BasicTeam.Id, s.th.BasicChannel.Name)) + s.Require().Contains(printer.GetErrorLines()[0], "Unable to unarchive channel. The channel is not archived.") + }) +} + +func (s *MmctlE2ETestSuite) TestDeleteChannelsCmd() { + s.SetupTestHelper().InitBasic() + + 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.NewId(), 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.Require().Equal(fmt.Sprintf("GetChannel: Unable to find the existing channel., resource: Channel id: %s", channel.Id), err.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.Require().Equal(fmt.Sprintf("GetChannel: Unable to find the existing channel., resource: Channel id: %s", notExistingChannelID), err.Error()) + }) +} + +func (s *MmctlE2ETestSuite) TestChannelRenameCmd() { + s.SetupTestHelper().InitBasic() + + 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() + 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() + + s.SetupTestHelper().InitBasic() + 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) + }) +} diff --git a/server/cmd/mmctl/commands/channel_test.go b/server/cmd/mmctl/commands/channel_test.go new file mode 100644 index 0000000000..10588a5131 --- /dev/null +++ b/server/cmd/mmctl/commands/channel_test.go @@ -0,0 +1,2953 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/web" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + userID = "userID" + userEmail = "user@example.com" + teamID = "teamID" + teamName = "teamName" + teamDisplayName = "teamDisplayName" + channelID = "channelID" + channelName = "channelName" + channelDisplayName = "channelDisplayName" +) + +func (s *MmctlUnitTestSuite) TestSearchChannelCmdF() { + s.Run("Search for an existing channel on an existing team", func() { + printer.Clean() + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Name: channelName} + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamID, "") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByName(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := searchChannelCmdF(s.client, cmd, []string{channelName}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(&mockChannel, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Search for an existing channel without specifying team", func() { + printer.Clean() + otherTeamID := "example-team-id-2" + mockTeams := []*model.Team{ + {Id: otherTeamID}, + {Id: teamID}, + } + mockChannel := model.Channel{Name: channelName} + + s.client. + EXPECT(). + GetAllTeams("", 0, 9999). + Return(mockTeams, &model.Response{}, nil). + Times(1) + + // first call is for the other team, that doesn't have the channel + s.client. + EXPECT(). + GetChannelByName(channelName, otherTeamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + // second call is for the team that contains the channel + s.client. + EXPECT(). + GetChannelByName(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := searchChannelCmdF(s.client, &cobra.Command{}, []string{channelName}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(&mockChannel, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Search for a nonexistent channel", func() { + printer.Clean() + mockTeam := model.Team{Id: teamID} + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamID, "") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByName(channelName, teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := searchChannelCmdF(s.client, cmd, []string{channelName}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "channel "+channelName+" was not found in team "+teamID) + }) + + s.Run("Search for a channel in a nonexistent team", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamID, "") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := searchChannelCmdF(s.client, cmd, []string{channelName}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "team "+teamID+" was not found") + }) +} + +func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { + s.Run("Both public and private the same value (false)", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", false, "") + cmd.Flags().Bool("private", false, "") + + err := modifyChannelCmdF(s.client, cmd, []string{}) + s.Require().EqualError(err, "you must specify only one of --public or --private") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Both public and private the same value (true)", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", true, "") + + err := modifyChannelCmdF(s.client, cmd, []string{}) + s.Require().EqualError(err, "you must specify only one of --public or --private") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to modify non-existing channel", func() { + printer.Clean() + args := []string{channelID} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel %q", args[0])) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to modify a channel from a non-existing team", func() { + printer.Clean() + team := "mockTeam" + channel := channelID + args := []string{team + ":" + channel} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetTeam(team, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(team, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel %q", args[0])) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to modify direct channel", func() { + printer.Clean() + channel := &model.Channel{ + Id: channelID, + Type: model.ChannelTypeDirect, + } + args := []string{channel.Id} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(channel, &model.Response{}, nil). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "you can only change the type of public/private channels") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to modify group channel", func() { + printer.Clean() + channel := &model.Channel{ + Id: channelID, + Type: model.ChannelTypeGroup, + } + args := []string{channel.Id} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(channel, &model.Response{}, nil). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "you can only change the type of public/private channels") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to modify channel privacy and get error", func() { + printer.Clean() + channel := &model.Channel{ + Id: channelID, + Type: model.ChannelTypePrivate, + } + mockError := errors.New("mock error") + + args := []string{channel.Id} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(channel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateChannelPrivacy(channel.Id, model.ChannelTypeOpen). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("failed to update channel (%q) privacy: %s", channel.Id, mockError.Error())) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Modify channel privacy to public", func() { + printer.Clean() + channel := &model.Channel{ + Id: channelID, + Type: model.ChannelTypePrivate, + } + returnedChannel := &model.Channel{ + Id: channel.Id, + Type: model.ChannelTypeOpen, + } + args := []string{channel.Id} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", true, "") + cmd.Flags().Bool("private", false, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(channel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateChannelPrivacy(channel.Id, model.ChannelTypeOpen). + Return(returnedChannel, &model.Response{}, nil). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().NoError(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Modify channel privacy to private", func() { + printer.Clean() + channel := &model.Channel{ + Id: channelID, + Type: model.ChannelTypeOpen, + } + returnedChannel := &model.Channel{ + Id: channel.Id, + Type: model.ChannelTypePrivate, + } + args := []string{channel.Id} + + cmd := &cobra.Command{} + cmd.Flags().String("username", "mockUser", "") + cmd.Flags().Bool("public", false, "") + cmd.Flags().Bool("private", true, "") + + s.client. + EXPECT(). + GetChannel(args[0], ""). + Return(channel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateChannelPrivacy(channel.Id, model.ChannelTypePrivate). + Return(returnedChannel, &model.Response{}, nil). + Times(1) + + err := modifyChannelCmdF(s.client, cmd, args) + s.Require().NoError(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { + s.Run("Archive channel without args returns an error", func() { + printer.Clean() + + err := archiveChannelsCmdF(s.client, &cobra.Command{}, []string{}) + mockErr := errors.New("enter at least one channel to archive") + + expected := mockErr.Error() + actual := err.Error() + + s.Require().Equal(expected, actual) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Archive an existing channel on an existing team", func() { + printer.Clean() + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID, Name: channelName} + + cmd := &cobra.Command{} + args := teamID + ":" + channelName + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteChannel(channelID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, []string{args}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Archive an existing channel specified by channel id", func() { + printer.Clean() + + mockChannel := model.Channel{Id: channelID, Name: channelName} + + cmd := &cobra.Command{} + args := []string{channelName} + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteChannel(channelID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Archive several channels specified by channel id", func() { + printer.Clean() + + channelArg1 := "some-channel" + channelID1 := "some-channel-id" + mockChannel1 := model.Channel{Id: channelID1, Name: channelArg1} + + channelArg2 := "some-other-channel" + channelID2 := "some-other-channel-id" + mockChannel2 := model.Channel{Id: channelID2, Name: channelArg2} + + cmd := &cobra.Command{} + args := []string{channelArg1, channelArg2} + + s.client. + EXPECT(). + GetChannel(channelArg1, ""). + Return(&mockChannel1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelArg2, ""). + Return(&mockChannel2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteChannel(channelID1). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteChannel(channelID2). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Fail to archive a channel on a non-existent team", func() { + printer.Clean() + + teamArg := "some-non-existent-team-id" + channelArg := "some-channel" + + cmd := &cobra.Command{} + args := []string{teamArg + ":" + channelArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + expected := printer.GetErrorLines()[0] + actual := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to archive a non-existing channel on an existent team", func() { + printer.Clean() + + teamArg := "some-non-existing-team-id" + mockTeam := model.Team{Id: teamArg} + channelArg := "some-non-existing-channel" + + cmd := &cobra.Command{} + args := []string{teamArg + ":" + channelArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelArg, teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + expected := printer.GetErrorLines()[0] + actual := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to archive a non-existing channel", func() { + printer.Clean() + + channelArg := "some-non-existing-channel" + cmd := &cobra.Command{} + args := []string{channelArg} + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + expected := printer.GetErrorLines()[0] + actual := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to archive an existing channel when client throws error", func() { + printer.Clean() + + channelArg := "some-channel" + channelID := "some-channel-id" + mockChannel := model.Channel{Id: channelID, Name: channelArg} + + cmd := &cobra.Command{} + args := []string{channelArg} + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + mockErr := errors.New("mock error") + s.client. + EXPECT(). + DeleteChannel(channelID). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr). + Times(1) + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + expected := printer.GetErrorLines()[0] + actual := fmt.Sprintf("Unable to archive channel '%s' error: %s", channelArg, mockErr.Error()) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to archive when team and channel not provided", func() { + printer.Clean() + cmd := &cobra.Command{} + args := []string{":"} + + err := archiveChannelsCmdF(s.client, cmd, args) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + expected := printer.GetErrorLines()[0] + actual := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Avoid path traversal with a valid team name", func() { + printer.Clean() + arg := "team:/../hello/channel-test" + + err := archiveChannelsCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Error(err) + s.Require().Equal("Unable to find channel 'team:/../hello/channel-test'", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestListChannelsCmd() { + emptyChannels := []*model.Channel{} + + s.Run("Team is not found", func() { + printer.Clean() + args := []string{""} + args[0] = teamID + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, "unable to find team \""+teamID+"\"") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("unable to find team \""+teamID+"\"", printer.GetErrorLines()[0]) + }) + + s.Run("Team has no channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + + team := &model.Team{ + Id: teamID, + } + + // Empty channels of a team + publicChannels := []*model.Channel{} + archivedChannels := []*model.Channel{} + privateChannels := []*model.Channel{} + userChannels := []*model.Channel{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(userChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Team with public channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + + team := &model.Team{ + Id: teamID, + } + + publicChannelName1 := "ChannelName1" + publicChannel1 := &model.Channel{Name: publicChannelName1} + + publicChannelName2 := "ChannelName2" + publicChannel2 := &model.Channel{Name: publicChannelName2} + + publicChannels := []*model.Channel{publicChannel1, publicChannel2} + archivedChannels := []*model.Channel{} // Empty archived channels + privateChannels := []*model.Channel{} // Empty private channels + userChannels := []*model.Channel{} // Empty user channels + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(userChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + s.Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], publicChannel1) + s.Require().Equal(printer.GetLines()[1], publicChannel2) + }) + + s.Run("Team with archived channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + + team := &model.Team{ + Id: teamID, + } + + archivedChannelName1 := "ChannelName1" + archivedChannel1 := &model.Channel{Name: archivedChannelName1} + + archivedChannelName2 := "ChannelName2" + archivedChannel2 := &model.Channel{Name: archivedChannelName2} + + publicChannels := []*model.Channel{} // Empty public channels + archivedChannels := []*model.Channel{archivedChannel1, archivedChannel2} + privateChannels := []*model.Channel{} // Empty private channels + userChannels := []*model.Channel{} // Empty user channels + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(userChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + s.Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], archivedChannel1) + s.Require().Equal(printer.GetLines()[1], archivedChannel2) + }) + + s.Run("Team with public, archived and private channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + + team := &model.Team{ + Id: teamID, + } + + archivedChannel1 := &model.Channel{Name: "archivedChannelName1"} + archivedChannel2 := &model.Channel{Name: "archivedChannelName2"} + archivedChannels := []*model.Channel{archivedChannel1, archivedChannel2} + + publicChannel1 := &model.Channel{Name: "publicChannelName1"} + publicChannel2 := &model.Channel{Name: "publicChannelName2"} + publicChannels := []*model.Channel{publicChannel1, publicChannel2} + + privateChannel1 := &model.Channel{Name: "archivedChannelName1"} + privateChannel2 := &model.Channel{Name: "archivedChannelName2"} + privateChannels := []*model.Channel{privateChannel1, privateChannel2} + userChannels := []*model.Channel{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(userChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + s.Len(printer.GetLines(), 6) + s.Require().Equal(printer.GetLines()[0], publicChannel1) + s.Require().Equal(printer.GetLines()[1], publicChannel2) + s.Require().Equal(printer.GetLines()[2], archivedChannel1) + s.Require().Equal(printer.GetLines()[3], archivedChannel2) + s.Require().Equal(printer.GetLines()[4], privateChannel1) + s.Require().Equal(printer.GetLines()[5], privateChannel2) + }) + + s.Run("User does not have permissions to get all private channels in team", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + team := &model.Team{ + Id: teamID, + } + cmd.PersistentFlags().Bool("local", false, "allows communicating with the server through a unix socket") + _ = viper.BindPFlag("local", cmd.PersistentFlags().Lookup("local")) + + archivedChannel1 := &model.Channel{Name: "archivedChannelName1"} + publicChannel1 := &model.Channel{Name: "publicChannelName1"} + + privateChannel1 := &model.Channel{Name: "archivedChannelName1", Type: model.ChannelTypePrivate} + privateChannel2 := &model.Channel{Name: "archivedChannelName2", Type: model.ChannelTypePrivate} + userChannels := []*model.Channel{archivedChannel1, publicChannel1, privateChannel1, privateChannel2} + + mockError := errors.New("user does not have permissions to list all private channels in team") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(userChannels, &model.Response{}, nil). + Times(1) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + s.Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], privateChannel1) + s.Require().Equal(printer.GetLines()[1], privateChannel2) + }) + + s.Run("API fails to get team's public channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + + team := &model.Team{ + Id: teamID, + } + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], fmt.Sprintf("unable to list public channels for %q: %s", args[0], mockError.Error())) + }) + + s.Run("API fails to get team's archived channels list", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + team := &model.Team{ + Id: teamID, + } + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], fmt.Sprintf("unable to list archived channels for %q: %s", args[0], mockError.Error())) + }) + + s.Run("API fails to get team's private channels list", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + team := &model.Team{ + Id: teamID, + } + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(emptyChannels, &model.Response{}, mockError). + Times(1) // falls through to GetChannelsForTeamForUser in non-local mode + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], fmt.Sprintf("unable to list private channels for %q: %s", args[0], mockError.Error())) + }) + + s.Run("API fails to get team's private channels list in local mode", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + cmd.PersistentFlags().Bool("local", true, "allows communicating with the server through a unix socket") + _ = viper.BindPFlag("local", cmd.PersistentFlags().Lookup("local")) + team := &model.Team{ + Id: teamID, + } + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(emptyChannels, &model.Response{}, mockError). + Times(0) // does not fall through to GetChannelsForTeamForUser in local mode + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], fmt.Sprintf("unable to list private channels for %q: %s", args[0], mockError.Error())) + }) + + s.Run("API fails to get team's public, archived and private channels", func() { + printer.Clean() + + args := []string{teamID} + cmd := &cobra.Command{} + cmd.PersistentFlags().Bool("local", false, "allows communicating with the server through a unix socket") + _ = viper.BindPFlag("local", cmd.PersistentFlags().Lookup("local")) + + team := &model.Team{ + Id: teamID, + } + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID, "me", false, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 3) + s.Require().Equal(printer.GetErrorLines()[0], fmt.Sprintf("unable to list public channels for %q: %s", args[0], mockError.Error())) + s.Require().Equal(printer.GetErrorLines()[1], fmt.Sprintf("unable to list archived channels for %q: %s", args[0], mockError.Error())) + s.Require().Equal(printer.GetErrorLines()[2], fmt.Sprintf("unable to list private channels for %q: %s", args[0], mockError.Error())) + }) + + s.Run("Two teams, one is found and other is not found", func() { + printer.Clean() + + teamID1 := "teamID1" + teamID2 := "teamID2" + args := []string{teamID1, teamID2} + cmd := &cobra.Command{} + + team1 := &model.Team{Id: teamID1} + + publicChannel1 := &model.Channel{Name: "publicChannelName1"} + publicChannel2 := &model.Channel{Name: "publicChannelName2"} + publicChannels := []*model.Channel{publicChannel1, publicChannel2} + + archivedChannel1 := &model.Channel{Name: "archivedChannelName1"} + archivedChannels := []*model.Channel{archivedChannel1} + + privateChannel1 := &model.Channel{Name: "privateChannelName1"} + privateChannels := []*model.Channel{privateChannel1} + + s.client. + EXPECT(). + GetTeam(teamID1, ""). + Return(team1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(teamID2, ""). + Return(nil, &model.Response{}, nil). // Team 2 not found + Times(1) + s.client. + EXPECT(). + GetTeamByName(teamID2, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID1, "me", false, ""). + Return(privateChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, "unable to find team \""+teamID2+"\"") + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("unable to find team \""+teamID2+"\"", printer.GetErrorLines()[0]) + s.Len(printer.GetLines(), 4) + s.Require().Equal(printer.GetLines()[0], publicChannel1) + s.Require().Equal(printer.GetLines()[1], publicChannel2) + s.Require().Equal(printer.GetLines()[2], archivedChannel1) + s.Require().Equal(printer.GetLines()[3], privateChannel1) + }) + + s.Run("Two teams, one is found and other has API errors", func() { + printer.Clean() + + teamID1 := "teamID1" + teamID2 := "teamID2" + args := []string{teamID1, teamID2} + cmd := &cobra.Command{} + + team1 := &model.Team{Id: teamID1} + team2 := &model.Team{Id: teamID2} + + publicChannel1 := &model.Channel{Name: "publicChannelName1"} + publicChannel2 := &model.Channel{Name: "publicChannelName2"} + publicChannels := []*model.Channel{publicChannel1, publicChannel2} + + archivedChannel1 := &model.Channel{Name: "archivedChannelName1"} + archivedChannels := []*model.Channel{archivedChannel1} + + privateChannel1 := &model.Channel{Name: "privateChannelName1"} + privateChannels := []*model.Channel{privateChannel1} + + s.client. + EXPECT(). + GetTeam(teamID1, ""). + Return(team1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID1, "me", false, ""). + Return(privateChannels, &model.Response{}, nil). + Times(0) + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID2, ""). + Return(team2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, mockError). + Times(1) + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID2, "me", false, ""). + Return(privateChannels, &model.Response{}, mockError). + Times(1) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, mockError.Error()) + s.Len(printer.GetErrorLines(), 3) + s.Len(printer.GetLines(), 4) + s.Require().Equal(printer.GetLines()[0], publicChannel1) + s.Require().Equal(printer.GetLines()[1], publicChannel2) + s.Require().Equal(printer.GetLines()[2], archivedChannel1) + s.Require().Equal(printer.GetLines()[3], privateChannel1) + }) + + s.Run("Two teams, both are not found", func() { + printer.Clean() + + team1ID := "team1ID" + team2ID := "team2ID" + args := []string{team1ID, team2ID} + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(team1ID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeam(team2ID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(team1ID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeamByName(team2ID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().ErrorContains(err, "unable to find team \""+team1ID+"\"") + s.Require().ErrorContains(err, "unable to find team \""+team2ID+"\"") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 2) + s.Require().Equal("unable to find team \""+team1ID+"\"", printer.GetErrorLines()[0]) + s.Require().Equal("unable to find team \""+team2ID+"\"", printer.GetErrorLines()[1]) + }) + + s.Run("Two teams, both have channels", func() { + printer.Clean() + + teamID1 := "teamID1" + teamID2 := "teamID2" + args := []string{teamID1, teamID2} + cmd := &cobra.Command{} + + team1 := &model.Team{Id: teamID1} + team2 := &model.Team{Id: teamID2} + + // Using same channel name for both teams since there can be common channels + publicChannel1 := &model.Channel{Name: "publicChannelName1"} + publicChannel2 := &model.Channel{Name: "publicChannelName2"} + publicChannels := []*model.Channel{publicChannel1, publicChannel2} + + archivedChannel1 := &model.Channel{Name: "archivedChannelName1"} + archivedChannels := []*model.Channel{archivedChannel1} + + privateChannel1 := &model.Channel{Name: "privateChannelName1"} + privateChannels := []*model.Channel{privateChannel1} + + s.client. + EXPECT(). + GetTeam(teamID1, ""). + Return(team1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID1, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID1, "me", false, ""). + Return(privateChannels, &model.Response{}, nil). + Times(0) + + s.client. + EXPECT(). + GetTeam(teamID2, ""). + Return(team2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(publicChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPublicChannelsForTeam(teamID2, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(archivedChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetDeletedChannelsForTeam(teamID2, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID2, 0, web.PerPageMaximum, ""). + Return(privateChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPrivateChannelsForTeam(teamID2, 1, web.PerPageMaximum, ""). + Return(emptyChannels, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelsForTeamForUser(teamID2, "me", false, ""). + Return(privateChannels, &model.Response{}, nil). + Times(0) + + err := listChannelsCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + s.Len(printer.GetLines(), 8) + s.Require().Equal(printer.GetLines()[0], publicChannel1) + s.Require().Equal(printer.GetLines()[1], publicChannel2) + s.Require().Equal(printer.GetLines()[2], archivedChannel1) + s.Require().Equal(printer.GetLines()[3], privateChannel1) + s.Require().Equal(printer.GetLines()[4], publicChannel1) + s.Require().Equal(printer.GetLines()[5], publicChannel2) + s.Require().Equal(printer.GetLines()[6], archivedChannel1) + s.Require().Equal(printer.GetLines()[7], privateChannel1) + }) + + s.Run("Avoid path traversal", func() { + printer.Clean() + arg := "\"test/../hello?\"channel-test" + + err := listChannelsCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().ErrorContains(err, "unable to find team \"\\\"test/../hello?\\\"channel-test\"") + s.Require().Equal("unable to find team \"\\\"test/../hello?\\\"channel-test\"", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { + s.Run("Unarchive channel without args returns an error", func() { + printer.Clean() + + err := unarchiveChannelsCmdF(s.client, &cobra.Command{}, []string{}) + mockErr := errors.New("enter at least one channel") + + expected := mockErr.Error() + actual := err.Error() + s.Require().Equal(expected, actual) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Unarchive an existing channel on an existing team", func() { + printer.Clean() + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID, Name: channelName} + + cmd := &cobra.Command{} + args := teamID + ":" + channelName + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreChannel(channelID). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, []string{args}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Unarchive an existing channel specified by channel id", func() { + printer.Clean() + + mockChannel := model.Channel{Id: channelID, Name: channelName} + + cmd := &cobra.Command{} + args := []string{channelName} + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreChannel(channelID). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Unarchive several channels specified by channel id", func() { + printer.Clean() + + channelArg1 := "some-channel" + channelID1 := "some-channel-id" + mockChannel1 := model.Channel{Id: channelID1, Name: channelArg1} + + channelArg2 := "some-other-channel" + channelID2 := "some-other-channel-id" + mockChannel2 := model.Channel{Id: channelID2, Name: channelArg2} + + cmd := &cobra.Command{} + args := []string{channelArg1, channelArg2} + + s.client. + EXPECT(). + GetChannel(channelArg1, ""). + Return(&mockChannel1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelArg2, ""). + Return(&mockChannel2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreChannel(channelID1). + Return(&mockChannel1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreChannel(channelID2). + Return(&mockChannel2, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Fail to unarchive a channel on a non-existent team", func() { + printer.Clean() + + teamArg := "some-non-existent-team-id" + + cmd := &cobra.Command{} + args := []string{teamArg + ":" + channelName} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + actual := printer.GetErrorLines()[0] + expected := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to unarchive a non-existing channel on an existent team", func() { + printer.Clean() + + teamArg := "some-non-existing-team-id" + mockTeam := model.Team{Id: teamArg} + channelArg := "some-non-existing-channel" + + cmd := &cobra.Command{} + args := []string{teamArg + ":" + channelArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelArg, teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + actual := printer.GetErrorLines()[0] + expected := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to unarchive a non-existing channel", func() { + printer.Clean() + + channelArg := "some-non-existing-channel" + cmd := &cobra.Command{} + args := []string{channelArg} + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + actual := printer.GetErrorLines()[0] + expected := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to unarchive an existing channel when client throws error", func() { + printer.Clean() + + mockChannel := model.Channel{Id: channelID, Name: channelName} + + cmd := &cobra.Command{} + args := []string{channelName} + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + mockErr := errors.New("mock error") + s.client. + EXPECT(). + RestoreChannel(channelID). + Return(nil, &model.Response{}, mockErr). + Times(1) + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + actual := printer.GetErrorLines()[0] + expected := fmt.Sprintf("Unable to unarchive channel '%s'. Error: %s", channelName, mockErr.Error()) + s.Require().Equal(expected, actual) + }) + + s.Run("Fail to unarchive when team and channel not provided", func() { + printer.Clean() + + cmd := &cobra.Command{} + args := []string{":"} + + err := unarchiveChannelsCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + + actual := printer.GetErrorLines()[0] + expected := fmt.Sprintf("Unable to find channel '%s'", args[0]) + s.Require().Equal(expected, actual) + }) +} + +func (s *MmctlUnitTestSuite) TestRenameChannelCmd() { + s.Run("It should fail when no name and display name is supplied", func() { + printer.Clean() + + cmd := &cobra.Command{} + + args := []string{""} + args[0] = "teamName:channelName" + + cmd.Flags().String("name", "", "Channel Name") + cmd.Flags().String("display-name", "", channelDisplayName) + cmd.Flags().String("display_name", "", "") + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "require at least one flag to rename channel, either 'name' or 'display-name'") + }) + + s.Run("It should fail when empty team and channel name are supplied", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "" + channelName := "" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel from %q", argsTeamChannel)) + }) + + s.Run("It should fail when empty channel is supplied", func() { + printer.Clean() + + cmd := &cobra.Command{} + + channelName := "" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel from %q", argsTeamChannel)) + }) + + s.Run("It should fail with empty team and non existing channel", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "" + channelName := "nonExistingChannelName" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel from %q", argsTeamChannel)) + }) + + s.Run("It should fail when team is not found", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "nonExistingteamName" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel from %q", argsTeamChannel)) + }) + + s.Run("It should fail when channel is not found", func() { + printer.Clean() + + cmd := &cobra.Command{} + + channelName := "nonExistingChannelName" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find channel from %q", argsTeamChannel)) + }) + + s.Run("It should fail when api fails to rename", func() { + printer.Clean() + + cmd := &cobra.Command{} + + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + + foundChannel := &model.Channel{ + Id: channelID, + Name: channelName, + DisplayName: channelDisplayName, + } + + channelPatch := &model.ChannelPatch{ + DisplayName: &newChannelDisplayName, + Name: &newChannelName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + mockError := model.NewAppError("at-random-location.go", "mock error", nil, "mocking a random error", 0) + s.client. + EXPECT(). + PatchChannel(foundChannel.Id, channelPatch). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("cannot rename channel %q, error: %s", foundChannel.Name, mockError.Error())) + }) + + s.Run("It should work as expected", func() { + printer.Clean() + + cmd := &cobra.Command{} + + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + + foundChannel := &model.Channel{ + Id: channelID, + Name: channelName, + DisplayName: channelDisplayName, + } + + channelPatch := &model.ChannelPatch{ + DisplayName: &newChannelDisplayName, + Name: &newChannelName, + } + + updatedChannel := &model.Channel{ + Id: channelID, + Name: newChannelName, + DisplayName: newChannelDisplayName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(foundChannel.Id, channelPatch). + Return(updatedChannel, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], updatedChannel) + }) + + s.Run("It should work with empty team and existing channel", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "" + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + foundChannel := &model.Channel{ + Id: channelID, + Name: channelName, + DisplayName: channelDisplayName, + } + + channelPatch := &model.ChannelPatch{ + DisplayName: &newChannelDisplayName, + Name: &newChannelName, + } + + updatedChannel := &model.Channel{ + Id: channelID, + Name: newChannelName, + DisplayName: newChannelDisplayName, + } + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(foundChannel.Id, channelPatch). + Return(updatedChannel, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], updatedChannel) + }) + + s.Run("It should work even if only name flag is passed", func() { + printer.Clean() + + cmd := &cobra.Command{} + + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "newChannelName" + newChannelDisplayName := "" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + cmd.Flags().String("display_name", "", "") + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + + foundChannel := &model.Channel{ + Id: channelID, + Name: channelName, + DisplayName: channelDisplayName, + } + + channelPatch := &model.ChannelPatch{ + Name: &newChannelName, + } + + updatedChannel := &model.Channel{ + Id: channelID, + Name: newChannelName, + DisplayName: newChannelDisplayName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(foundChannel.Id, channelPatch). + Return(updatedChannel, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], updatedChannel) + }) + + s.Run("It should work even if only display name flag is passed", func() { + printer.Clean() + + cmd := &cobra.Command{} + + argsTeamChannel := teamName + ":" + channelName + args := []string{argsTeamChannel} + + newChannelName := "" + newChannelDisplayName := "New Channel Name" + cmd.Flags().String("name", newChannelName, "Channel Name") + cmd.Flags().String("display-name", newChannelDisplayName, channelDisplayName) + + foundTeam := &model.Team{ + Id: teamID, + DisplayName: teamDisplayName, + Name: teamName, + } + + foundChannel := &model.Channel{ + Id: channelID, + Name: channelName, + DisplayName: channelDisplayName, + } + + channelPatch := &model.ChannelPatch{ + DisplayName: &newChannelDisplayName, + } + + updatedChannel := &model.Channel{ + Id: channelID, + Name: newChannelName, + DisplayName: newChannelDisplayName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(foundChannel.Id, channelPatch). + Return(updatedChannel, &model.Response{}, nil). + Times(1) + + err := renameChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], updatedChannel) + }) +} + +func (s *MmctlUnitTestSuite) TestMoveChannelCmdF() { + s.Run("Move a channel to another team by using names", func() { + printer.Clean() + + dstTeamName := "destination-team-name" + dstTeamID := "destination-team-id" + mockTeam1 := model.Team{ + Name: dstTeamName, + Id: dstTeamID, + } + + srcTeamName := "source-team-name" + srcTeamID := "source-team-id" + mockTeam2 := model.Team{ + Name: srcTeamName, + Id: srcTeamID, + } + + channelName := "channel-name" + channelID := "channel-id" + mockChannel := model.Channel{ + Name: channelName, + TeamId: mockTeam2.Id, + Id: channelID, + } + + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(dstTeamName, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(dstTeamName, ""). + Return(&mockTeam1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(srcTeamName, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(srcTeamName, ""). + Return(&mockTeam2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, mockTeam2.Id, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + MoveChannel(mockChannel.Id, mockTeam1.Id, false). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := moveChannelCmdF(s.client, cmd, []string{dstTeamName, srcTeamName + ":" + channelName}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(&mockChannel, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should fail for not being able to find the destination team", func() { + printer.Clean() + + dstTeamName := "destination-team-name" + + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(dstTeamName, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(dstTeamName, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + err := moveChannelCmdF(s.client, cmd, []string{dstTeamName, "team:channel"}) + + s.Require().EqualError(err, fmt.Sprintf("unable to find destination team %q", dstTeamName)) + }) + + s.Run("Should fail for not being able to find the channel", func() { + printer.Clean() + + dstTeamName := "destination-team-name" + dstTeamID := "destination-team-id" + mockTeam1 := model.Team{ + Name: dstTeamName, + Id: dstTeamID, + } + + channelID := "channel-id" + + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(dstTeamID, ""). + Return(&mockTeam1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + err := moveChannelCmdF(s.client, cmd, []string{dstTeamID, channelID}) + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable to find channel %q", channelID)) + + s.Require().EqualError(err, expected.Error()) + }) + + s.Run("Fail on client.MoveChannel to another team by using Ids", func() { + printer.Clean() + + dstTeamID := "destination-team-id" + mockTeam1 := model.Team{ + Id: dstTeamID, + } + + channelID := "channel-id" + + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(dstTeamID, ""). + Return(&mockTeam1, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(&model.Channel{Id: channelID, Name: "some-name"}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + MoveChannel(channelID, mockTeam1.Id, false). + Return(nil, &model.Response{}, errors.New("some-error")). + Times(1) + + err := moveChannelCmdF(s.client, cmd, []string{dstTeamID, channelID}) + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable to move channel %q: some-error", "some-name")) + + s.Require().EqualError(err, expected.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestCreateChannelCmd() { + s.Run("should not create channel without display name", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelName := "channelName" + args := []string{teamName + ":" + channelName} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("name", channelName, "Channel Name") + + err := createChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "display Name is required") + }) + + s.Run("should not create channel without name", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelDisplayName := "channelDisplayName" + argsTeamChannel := teamName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + + err := createChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "name is required") + }) + + s.Run("should not create channel without team", func() { + printer.Clean() + + cmd := &cobra.Command{} + + channelName := "channelName" + channelDisplayName := "channelDisplayName" + argsTeamChannel := channelName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("name", channelName, "Channel Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + + err := createChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, "team is required") + }) + + s.Run("should fail when team does not exist", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelName := "channelName" + channelDisplayName := "channelDisplayName" + argsTeamChannel := teamName + ":" + channelName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("name", channelName, "Channel Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := createChannelCmdF(s.client, cmd, args) + s.Require().EqualError(err, fmt.Sprintf("unable to find team: %s", teamName)) + }) + + s.Run("should create public channel", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelName := "channelName" + channelDisplayName := "channelDisplayName" + argsTeamChannel := teamName + ":" + channelName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("name", channelName, "Channel Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + + foundTeam := &model.Team{ + Id: "teamId", + Name: teamName, + DisplayName: "teamDisplayName", + } + + foundChannel := &model.Channel{ + TeamId: "teamId", + Name: channelName, + DisplayName: channelDisplayName, + Type: model.ChannelTypeOpen, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateChannel(foundChannel). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + err := createChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], foundChannel) + }) + + s.Run("should create private channel", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelName := "channelName" + channelDisplayName := "channelDisplayName" + argsTeamChannel := teamName + ":" + channelName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("name", channelName, "Channel Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + cmd.Flags().Bool("private", true, "Create a private channel") + + foundTeam := &model.Team{ + Id: "teamId", + Name: teamName, + DisplayName: "teamDisplayName", + } + + foundChannel := &model.Channel{ + TeamId: "teamId", + Name: channelName, + DisplayName: channelDisplayName, + Type: model.ChannelTypePrivate, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateChannel(foundChannel). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + err := createChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], foundChannel) + }) + + s.Run("should create channel with header and purpose", func() { + printer.Clean() + + cmd := &cobra.Command{} + + teamName := "teamName" + channelName := "channelName" + channelDisplayName := "channelDisplayName" + header := "someHeader" + purpose := "somePurpose" + argsTeamChannel := teamName + ":" + channelName + ":" + channelDisplayName + args := []string{argsTeamChannel} + + cmd.Flags().String("team", teamName, "Team Name") + cmd.Flags().String("name", channelName, "Channel Name") + cmd.Flags().String("display-name", channelDisplayName, "Channel Display Name") + cmd.Flags().String("header", header, "Channel header") + cmd.Flags().String("purpose", purpose, "Channel purpose") + cmd.Flags().Bool("private", true, "Create a private channel") + + foundTeam := &model.Team{ + Id: "teamId", + Name: teamName, + DisplayName: "teamDisplayName", + } + + foundChannel := &model.Channel{ + TeamId: "teamId", + Name: channelName, + DisplayName: channelDisplayName, + Header: header, + Purpose: purpose, + Type: model.ChannelTypePrivate, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateChannel(foundChannel). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + err := createChannelCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], foundChannel) + }) +} + +func (s *MmctlUnitTestSuite) TestDeleteChannelsCmd() { + teamName := "team1" + teamID := "teamId" + mockTeam := model.Team{ + Name: teamName, + Id: teamID, + } + + channelName := "channel1" + channelID := "channel1Id" + mockChannel := model.Channel{ + Name: channelName, + Id: channelID, + } + + s.Run("Delete channels without confirm flag returns an error", func() { + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", false, "") + err := deleteChannelsCmdF(s.client, cmd, []string{"some"}) + s.Require().NotNil(err) + s.Require().Equal("could not proceed, either enable --confirm flag or use an interactive shell to complete operation: this is not an interactive shell", err.Error()) + }) + + s.Run("Delete channel that does not exist in db returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + arg := teamID + ":" + channelName + err := deleteChannelsCmdF(s.client, cmd, []string{arg}) + var expected error + expected = multierror.Append(expected, errors.New("unable to find channel '"+arg+"'")) + s.Require().EqualError(err, expected.Error()) + }) + + s.Run("Delete channel from team that does not exist in db returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + arg := teamName + ":" + channelName + err := deleteChannelsCmdF(s.client, cmd, []string{arg}) + + var expected error + expected = multierror.Append(expected, errors.New("unable to find channel '"+arg+"'")) + s.Require().EqualError(err, expected.Error()) + }) + + s.Run("Delete channel should delete channel", func() { + printer.Clean() + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, nil, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteChannel(channelID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + arg := teamID + ":" + channelName + err := deleteChannelsCmdF(s.client, cmd, []string{arg}) + s.Require().Nil(err) + s.Require().Equal(&mockChannel, printer.GetLines()[0]) + }) + + s.Run("Delete two channels, first one does not exist", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, nil, nil). + Times(2) + + channelNameDoesNotExist := "this channel does not exist" + mockError := errors.New("channel does not exist error") + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelNameDoesNotExist, teamID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelNameDoesNotExist, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, nil, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteChannel(channelID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + arg1 := teamID + ":" + channelNameDoesNotExist + arg2 := teamID + ":" + channelName + err := deleteChannelsCmdF(s.client, cmd, []string{arg1, arg2}) + + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable to find channel '%s'", arg1)) + s.Require().EqualError(err, expected.Error()) + s.Require().Equal(&mockChannel, printer.GetLines()[0]) + }) +} diff --git a/server/cmd/mmctl/commands/channel_users.go b/server/cmd/mmctl/commands/channel_users.go new file mode 100644 index 0000000000..a5d1a97287 --- /dev/null +++ b/server/cmd/mmctl/commands/channel_users.go @@ -0,0 +1,135 @@ +// 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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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), +} + +func init() { + ChannelUsersRemoveCmd.Flags().Bool("all-users", false, "Remove all users from the indicated channel.") + + ChannelUsersCmd.AddCommand( + ChannelUsersAddCmd, + ChannelUsersRemoveCmd, + ) + + 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]) + } + + users := getUsersFromUserArgs(c, args[1:]) + for i, user := range users { + addUserToChannel(c, channel, user, args[i+1]) + } + + return nil +} + +func addUserToChannel(c client.Client, channel *model.Channel, user *model.User, userArg string) { + if user == nil { + printer.PrintError("Can't find user '" + userArg + "'") + return + } + if _, _, err := c.AddChannelMember(channel.Id, user.Id); err != nil { + printer.PrintError("Unable to add '" + userArg + "' to " + channel.Name + ". Error: " + err.Error()) + } +} + +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]) + } + + if allUsers { + if err := removeAllUsersFromChannel(c, channel); err != nil { + return err + } + } else { + for i, user := range getUsersFromUserArgs(c, args[1:]) { + removeUserFromChannel(c, channel, user, args[i+1]) + } + } + + return nil +} + +func removeUserFromChannel(c client.Client, channel *model.Channel, user *model.User, userArg string) { + if user == nil { + printer.PrintError("Can't find user '" + userArg + "'") + return + } + if _, err := c.RemoveUserFromChannel(channel.Id, user.Id); err != nil { + printer.PrintError("Unable to remove '" + userArg + "' from " + channel.Name + ". Error: " + err.Error()) + } +} + +func removeAllUsersFromChannel(c client.Client, channel *model.Channel) error { + var result *multierror.Error + members, _, err := c.GetChannelMembers(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(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() +} diff --git a/server/cmd/mmctl/commands/channel_users_e2e_test.go b/server/cmd/mmctl/commands/channel_users_e2e_test.go new file mode 100644 index 0000000000..a690c8936d --- /dev/null +++ b/server/cmd/mmctl/commands/channel_users_e2e_test.go @@ -0,0 +1,290 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/mattermost/mattermost-server/server/v8/channels/app" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestChannelUsersAddCmdF() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Can't find user '%s'", nonexistentUserName), printer.GetErrorLines()[0]) + }) + + 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Can't find user '%s'", nonexistentUserName), printer.GetErrorLines()[0]) + }) + + 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Unable to add '%s' to %s. 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) TestChannelUsersRemoveCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Can't find user '%s'", 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Can't find user '%s'", 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().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("Unable to remove '%s' from %s", 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) + }) +} diff --git a/server/cmd/mmctl/commands/channel_users_test.go b/server/cmd/mmctl/commands/channel_users_test.go new file mode 100644 index 0000000000..3576553ade --- /dev/null +++ b/server/cmd/mmctl/commands/channel_users_test.go @@ -0,0 +1,441 @@ +// 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/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/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(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(userEmail, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + AddChannelMember(channelID, userID). + Return(&model.ChannelMember{}, &model.Response{}, nil). + Times(1) + 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(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + // No channel is returned by client. + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetChannel(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(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeamByName(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(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(nilUserArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByUsername(nilUserArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUser(nilUserArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(userEmail, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + AddChannelMember(channelID, userID). + Return(&model.ChannelMember{}, &model.Response{}, nil). + Times(1) + err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, nilUserArg, userEmail}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Can't find user '"+nilUserArg+"'", printer.GetErrorLines()[0]) + }) + s.Run("Error adding existing user to existing channel", func() { + printer.Clean() + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(userEmail, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + AddChannelMember(channelID, userID). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + err := channelUsersAddCmdF(s.client, cmd, []string{channelArg, userEmail}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to add '"+userEmail+"' to "+channelName+". Error: mock error", + printer.GetErrorLines()[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(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(userEmail, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(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(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelMembers(foundChannel.Id, 0, 10000, ""). + Return(mockChannelMembers, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(foundChannel.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(foundChannel.Id, mockUser2.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(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(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(userEmail, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser2.Email, ""). + Return(&mockUser2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(foundChannel.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(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(teamName, ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelName, foundTeam.Id, ""). + Return(foundChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelMembers(foundChannel.Id, 0, 10000, ""). + Return(mockChannelMembers, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RemoveUserFromChannel(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) + }) +} diff --git a/server/cmd/mmctl/commands/channelargs.go b/server/cmd/mmctl/commands/channelargs.go new file mode 100644 index 0000000000..7f5901c4ec --- /dev/null +++ b/server/cmd/mmctl/commands/channelargs.go @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/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(channelPart, team.Id, "") + } + + if channel == nil { + channel, _, _ = c.GetChannel(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(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(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 +} diff --git a/server/cmd/mmctl/commands/channelargs_test.go b/server/cmd/mmctl/commands/channelargs_test.go new file mode 100644 index 0000000000..2bb5b0ff4b --- /dev/null +++ b/server/cmd/mmctl/commands/channelargs_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "net/http" + + "github.com/mattermost/mattermost-server/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(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(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(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(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(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(teamID, ""). + Return(successTeam, nil, nil). + Times(1) + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(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]) + }) +} diff --git a/server/cmd/mmctl/commands/command.go b/server/cmd/mmctl/commands/command.go new file mode 100644 index 0000000000..6c22daff3f --- /dev/null +++ b/server/cmd/mmctl/commands/command.go @@ -0,0 +1,351 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 CommandDeleteCmd = &cobra.Command{ + Use: "delete [commandID]", + Short: "Delete a slash command", + Long: `Delete a slash command. Commands can be specified by command ID.`, + Example: ` command delete commandID`, + Deprecated: "please use \"archive\" instead", + Args: cobra.ExactArgs(1), + RunE: withClient(archiveCommandCmdF), +} + +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, + CommandDeleteCmd, + 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(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 := c.GetAllTeams("", 0, 10000) + 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(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(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]interface{}{"status": "ok"}) + } else { + printer.PrintT("Status: {{.status}}", map[string]interface{}{"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(command) + if err != nil { + return fmt.Errorf("unable to modify command '%s'. %s", command.DisplayName, err.Error()) + } + + 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(newTeam.Id, command.Id) + if err != nil { + return fmt.Errorf("unable to move command '%s'. %s", command.Id, err.Error()) + } + + if resp.StatusCode == http.StatusOK { + printer.PrintT("Status: {{.status}}", map[string]interface{}{"status": "ok"}) + } else { + printer.PrintT("Status: {{.status}}", map[string]interface{}{"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 +} diff --git a/server/cmd/mmctl/commands/command_e2e_test.go b/server/cmd/mmctl/commands/command_e2e_test.go new file mode 100644 index 0000000000..5038f41693 --- /dev/null +++ b/server/cmd/mmctl/commands/command_e2e_test.go @@ -0,0 +1,319 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestListCommandCmd() { + s.SetupTestHelper().InitBasic() + + 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() + + 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]interface{}{"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() + + // 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(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") + }) +} diff --git a/server/cmd/mmctl/commands/command_test.go b/server/cmd/mmctl/commands/command_test.go new file mode 100644 index 0000000000..4ac614c948 --- /dev/null +++ b/server/cmd/mmctl/commands/command_test.go @@ -0,0 +1,1062 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestCommandCreateCmd() { + s.Run("Create a new custom slash command for a specified team", func() { + printer.Clean() + teamArg := "example-team-id" + titleArg := "example-command-name" + descriptionArg := "example-description-text" + triggerWordArg := "example-trigger-word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + creatorUsernameArg := "example-user" + responseUsernameArg := "example-username2" + iconArg := "icon-url" + method := "G" + autocomplete := false + autocompleteDesc := "autocompleteDesc" + autocompleteHint := "autocompleteHint" + + mockTeam := model.Team{Id: teamArg, Name: "TeamRed"} + mockUser := model.User{Id: creatorIDArg, Username: creatorUsernameArg} + mockCommand := model.Command{ + TeamId: teamArg, + DisplayName: titleArg, + Description: descriptionArg, + Trigger: triggerWordArg, + URL: urlArg, + CreatorId: creatorIDArg, + Username: responseUsernameArg, + IconURL: iconArg, + Method: method, + AutoComplete: autocomplete, + AutoCompleteDesc: autocompleteDesc, + AutoCompleteHint: autocompleteHint, + } + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + cmd.Flags().String("title", titleArg, "") + cmd.Flags().String("description", descriptionArg, "") + cmd.Flags().String("trigger-word", triggerWordArg, "") + cmd.Flags().String("url", urlArg, "") + cmd.Flags().String("creator", creatorIDArg, "") + cmd.Flags().String("response-username", responseUsernameArg, "") + cmd.Flags().String("icon", iconArg, "") + cmd.Flags().String("method", method, "") + cmd.Flags().Bool("autocomplete", autocomplete, "") + cmd.Flags().String("autocompleteDesc", autocompleteDesc, "") + cmd.Flags().String("autocompleteHint", autocompleteHint, "") + + // createCommandCmdF will call getTeamFromTeamArg, getUserFromUserArg which then calls GetUserByEmail + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(creatorIDArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + CreateCommand(&mockCommand). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(&mockCommand, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a slash command only providing team, trigger word, url, creator", func() { + printer.Clean() + teamArg := "example-team-id" + triggerWordArg := "example-trigger-word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + creatorUsernameArg := "example-user" + method := "G" + + mockTeam := model.Team{Id: teamArg} + mockUser := model.User{Id: creatorIDArg, Username: creatorUsernameArg} + mockCommand := model.Command{ + TeamId: teamArg, + Trigger: triggerWordArg, + URL: urlArg, + CreatorId: creatorIDArg, + Method: method, + } + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + cmd.Flags().String("trigger-word", triggerWordArg, "") + cmd.Flags().String("url", urlArg, "") + cmd.Flags().String("creator", creatorIDArg, "") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(creatorIDArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + CreateCommand(&mockCommand). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(&mockCommand, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create slash command for a nonexistent team", func() { + printer.Clean() + teamArg := "example-team-id" + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "unable to find team '"+teamArg+"'") + }) + + s.Run("Create slash command with a space in trigger word", func() { + printer.Clean() + teamArg := "example-team-id" + titleArg := "example-command-name" + descriptionArg := "example-description-text" + triggerWordArg := "example trigger word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + creatorUsernameArg := "example-user" + responseUsernameArg := "example-username2" + iconArg := "icon-url" + method := "G" + autocomplete := false + autocompleteDesc := "autocompleteDesc" + autocompleteHint := "autocompleteHint" + + mockTeam := model.Team{Id: teamArg} + mockUser := model.User{Id: creatorIDArg, Username: creatorUsernameArg} + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + cmd.Flags().String("title", titleArg, "") + cmd.Flags().String("description", descriptionArg, "") + cmd.Flags().String("trigger-word", triggerWordArg, "") + cmd.Flags().String("url", urlArg, "") + cmd.Flags().String("creator", creatorIDArg, "") + cmd.Flags().String("response-username", responseUsernameArg, "") + cmd.Flags().String("icon", iconArg, "") + cmd.Flags().String("method", method, "") + cmd.Flags().Bool("autocomplete", autocomplete, "") + cmd.Flags().String("autocompleteDesc", autocompleteDesc, "") + cmd.Flags().String("autocompleteHint", autocompleteHint, "") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(creatorIDArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "a trigger word must not contain spaces") + }) + + s.Run("Create slash command with trigger word prefixed with /", func() { + printer.Clean() + teamArg := "example-team-id" + titleArg := "example-command-name" + descriptionArg := "example-description-text" + triggerWordArg := "/example-trigger-word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + creatorUsernameArg := "example-user" + responseUsernameArg := "example-username2" + iconArg := "icon-url" + method := "G" + autocomplete := false + autocompleteDesc := "autocompleteDesc" + autocompleteHint := "autocompleteHint" + + mockTeam := model.Team{Id: teamArg} + mockUser := model.User{Id: creatorIDArg, Username: creatorUsernameArg} + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + cmd.Flags().String("title", titleArg, "") + cmd.Flags().String("description", descriptionArg, "") + cmd.Flags().String("trigger-word", triggerWordArg, "") + cmd.Flags().String("url", urlArg, "") + cmd.Flags().String("creator", creatorIDArg, "") + cmd.Flags().String("response-username", responseUsernameArg, "") + cmd.Flags().String("icon", iconArg, "") + cmd.Flags().String("method", method, "") + cmd.Flags().Bool("autocomplete", autocomplete, "") + cmd.Flags().String("autocompleteDesc", autocompleteDesc, "") + cmd.Flags().String("autocompleteHint", autocompleteHint, "") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(creatorIDArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "a trigger word cannot begin with a /") + }) + + s.Run("Create slash command fail", func() { + printer.Clean() + teamArg := "example-team-id" + titleArg := "example-command-name" + descriptionArg := "example-description-text" + triggerWordArg := "example-trigger-word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + creatorUsernameArg := "example-user" + responseUsernameArg := "example-username2" + iconArg := "icon-url" + method := "G" + autocomplete := false + autocompleteDesc := "autocompleteDesc" + autocompleteHint := "autocompleteHint" + + mockTeam := model.Team{Id: teamArg} + mockUser := model.User{Id: creatorIDArg, Username: creatorUsernameArg} + mockCommand := model.Command{ + TeamId: teamArg, + DisplayName: titleArg, + Description: descriptionArg, + Trigger: triggerWordArg, + URL: urlArg, + CreatorId: creatorIDArg, + Username: responseUsernameArg, + IconURL: iconArg, + Method: method, + AutoComplete: autocomplete, + AutoCompleteDesc: autocompleteDesc, + AutoCompleteHint: autocompleteHint, + } + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamArg, "") + cmd.Flags().String("title", titleArg, "") + cmd.Flags().String("description", descriptionArg, "") + cmd.Flags().String("trigger-word", triggerWordArg, "") + cmd.Flags().String("url", urlArg, "") + cmd.Flags().String("creator", creatorIDArg, "") + cmd.Flags().String("response-username", responseUsernameArg, "") + cmd.Flags().String("icon", iconArg, "") + cmd.Flags().String("method", method, "") + cmd.Flags().Bool("autocomplete", autocomplete, "") + cmd.Flags().String("autocompleteDesc", autocompleteDesc, "") + cmd.Flags().String("autocompleteHint", autocompleteHint, "") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(creatorIDArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + mockError := errors.New("mock error, simulated error for CreateCommand") + s.client. + EXPECT(). + CreateCommand(&mockCommand). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := createCommandCmdF(s.client, cmd, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "unable to create command '"+mockCommand.DisplayName+"'. "+mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestArchiveCommandCmd() { + s.Run("Delete without errors", func() { + printer.Clean() + arg := "cmd1" + outputMessage := map[string]interface{}{"status": "ok"} + + s.client. + EXPECT(). + DeleteCommand(arg). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := archiveCommandCmdF(s.client, &cobra.Command{}, []string{arg}) + 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 delete", func() { + printer.Clean() + arg := "cmd1" + outputMessage := map[string]interface{}{"status": "error"} + + s.client. + EXPECT(). + DeleteCommand(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := archiveCommandCmdF(s.client, &cobra.Command{}, []string{arg}) + 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("Delete with response error", func() { + printer.Clean() + arg := "cmd1" + mockError := errors.New("mock error") + + s.client. + EXPECT(). + DeleteCommand(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := archiveCommandCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().NotNil(err) + s.Require().Equal(err, errors.New("Unable to archive command '"+arg+"' error: "+mockError.Error())) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestCommandListCmdF() { + s.Run("List all commands from all teams", func() { + printer.Clean() + team1ID := "team-id-1" + team2Id := "team-id-2" + + commandTeam1ID := "command-team1-id" + commandTeam2Id := "command-team2-id" + teams := []*model.Team{ + {Id: team1ID}, + {Id: team2Id}, + } + + team1Commands := []*model.Command{ + { + Id: commandTeam1ID, + }, + } + team2Commands := []*model.Command{ + { + Id: commandTeam2Id, + }, + } + + cmd := &cobra.Command{} + s.client.EXPECT().GetAllTeams("", 0, 10000).Return(teams, &model.Response{}, nil).Times(1) + s.client.EXPECT().ListCommands(team1ID, true).Return(team1Commands, &model.Response{}, nil).Times(1) + s.client.EXPECT().ListCommands(team2Id, true).Return(team2Commands, &model.Response{}, nil).Times(1) + err := listCommandCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 2) + s.Equal(team1Commands[0], printer.GetLines()[0]) + s.Equal(team2Commands[0], printer.GetLines()[1]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List commands for a specific team", func() { + printer.Clean() + teamID := "team-id" + commandID := "command-id" + team := &model.Team{Id: teamID} + teamCommand := []*model.Command{ + { + Id: commandID, + }, + } + + cmd := &cobra.Command{} + s.client.EXPECT().GetTeam(teamID, "").Return(team, &model.Response{}, nil).Times(1) + s.client.EXPECT().ListCommands(teamID, true).Return(teamCommand, &model.Response{}, nil).Times(1) + err := listCommandCmdF(s.client, cmd, []string{teamID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(teamCommand[0], printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List commands for a non existing team", func() { + teamID := "non-existing-team" + printer.Clean() + cmd := &cobra.Command{} + // first try to get team by id + s.client.EXPECT().GetTeam(teamID, "").Return(nil, &model.Response{}, nil).Times(1) + // second try to search the team by name + s.client.EXPECT().GetTeamByName(teamID, "").Return(nil, &model.Response{}, nil).Times(1) + err := listCommandCmdF(s.client, cmd, []string{teamID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to find team '"+teamID+"'", printer.GetErrorLines()[0]) + }) + + s.Run("Failling to list commands for an existing team", func() { + teamID := "team-id" + printer.Clean() + cmd := &cobra.Command{} + team := &model.Team{Id: teamID} + s.client.EXPECT().GetTeam(teamID, "").Return(team, &model.Response{}, nil).Times(1) + s.client.EXPECT().ListCommands(teamID, true).Return(nil, &model.Response{}, errors.New("")).Times(1) + err := listCommandCmdF(s.client, cmd, []string{teamID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to list commands for '"+teamID+"'", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestCommandModifyCmd() { + arg := "cmd1" + teamID := "example-team-id" + titleArg := "example-command-name" + descriptionArg := "example-description-text" + triggerWordArg := "example-trigger-word" + urlArg := "http://localhost:8000/example" + creatorIDArg := "example-user-id" + responseUsernameArg := "example-username2" + iconArg := "icon-url" + method := "G" + autocomplete := false + autocompleteDesc := "autocompleteDesc" + autocompleteHint := "autocompleteHint" + + mockCommand := model.Command{ + TeamId: teamID, + DisplayName: titleArg, + Description: descriptionArg, + Trigger: triggerWordArg, + URL: urlArg, + CreatorId: creatorIDArg, + Username: responseUsernameArg, + IconURL: iconArg, + Method: method, + AutoComplete: autocomplete, + AutoCompleteDesc: autocompleteDesc, + AutoCompleteHint: autocompleteHint, + } + + s.Run("Modify a custom slash command by id", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.DisplayName = titleArg + "_modified" + mockCommandModified.Description = descriptionArg + "_modified" + mockCommandModified.Trigger = triggerWordArg + "_modified" + mockCommandModified.URL = urlArg + "_modified" + mockCommandModified.CreatorId = creatorIDArg + "_modified" + mockCommandModified.Username = responseUsernameArg + "_modified" + mockCommandModified.IconURL = iconArg + "_modified" + mockCommandModified.Method = method + mockCommandModified.AutoComplete = !autocomplete + mockCommandModified.AutoCompleteDesc = autocompleteDesc + "_modified" + mockCommandModified.AutoCompleteHint = autocompleteHint + "_modified" + + cli := []string{ + arg, + "--title=" + mockCommandModified.DisplayName, + "--description=" + mockCommandModified.Description, + "--trigger-word=" + mockCommandModified.Trigger, + "--url=" + mockCommandModified.URL, + "--creator=" + mockCommandModified.CreatorId, + "--response-username=" + mockCommandModified.Username, + "--icon=" + mockCommandModified.IconURL, + "--autocomplete=" + strconv.FormatBool(mockCommandModified.AutoComplete), + "--autocompleteDesc=" + mockCommandModified.AutoCompleteDesc, + "--autocompleteHint=" + mockCommandModified.AutoCompleteHint, + "--post=" + strconv.FormatBool(method2Bool(mockCommandModified.Method)), + } + + // modifyCommandCmdF will call getCommandById, GetUserByEmail and UpdateCommand + s.client. + EXPECT(). + GetCommandById(arg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(mockCommandModified.CreatorId, ""). + Return(&model.User{Id: mockCommandModified.CreatorId}, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + UpdateCommand(&mockCommand). + Return(mockCommandModified, &model.Response{}, nil). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(mockCommandModified, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Modify slash command using a nonexistent commandID", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.DisplayName = titleArg + "_modified" + + cli := []string{ + arg, + "--title=" + mockCommandModified.DisplayName, + } + + // modifyCommandCmdF will call getCommandById + s.client. + EXPECT(). + GetCommandById(arg). + Return(nil, &model.Response{}, nil). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true for all flags on the CLI. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "unable to find command '"+arg+"'") + }) + + s.Run("Modify slash command with invalid user name", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.CreatorId = creatorIDArg + "_modified" + + bogusUsername := "bogus" + cli := []string{ + arg, + "--creator=" + bogusUsername, + } + + // modifyCommandCmdF will call getCommandById, then try looking up user + // via email, username, and id. + s.client. + EXPECT(). + GetCommandById(arg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(bogusUsername, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByUsername(bogusUsername, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUser(bogusUsername, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true for all flags on the CLI. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "unable to find user '"+bogusUsername+"'") + }) + + s.Run("Modify slash command with a space in trigger word", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.Trigger = creatorIDArg + " modified with space" + + cli := []string{ + arg, + "--trigger-word=" + mockCommandModified.Trigger, + } + + // modifyCommandCmdF will call getCommandById + s.client. + EXPECT(). + GetCommandById(arg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true for all flags on the CLI. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "a trigger word must not contain spaces") + }) + + s.Run("Modify slash command with trigger word prefixed with /", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.Trigger = "/modified_with_slash" + + cli := []string{ + arg, + "--trigger-word=" + mockCommandModified.Trigger, + } + + // modifyCommandCmdF will call getCommandById + s.client. + EXPECT(). + GetCommandById(arg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true for all flags on the CLI. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "a trigger word cannot begin with a /") + }) + + s.Run("Modify slash command fail", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.Trigger = creatorIDArg + "_modified" + + cli := []string{ + arg, + "--trigger-word=" + mockCommandModified.Trigger, + } + + // modifyCommandCmdF will call getCommandById then UpdateCommand + s.client. + EXPECT(). + GetCommandById(arg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + mockError := errors.New("mock error, simulated error for CreateCommand") + s.client. + EXPECT(). + UpdateCommand(&mockCommand). + Return(nil, &model.Response{}, mockError). + Times(1) + + // Reset the cmd and parse to force Flag.Changed to be true for all flags on the CLI. + cmd := CommandModifyCmd + cmd.ResetFlags() + addCommandFieldsFlags(cmd) + err := cmd.ParseFlags(cli) + s.Require().Nil(err) + + err = modifyCommandCmdF(s.client, cmd, []string{arg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "unable to modify command '"+mockCommand.DisplayName+"'. "+mockError.Error()) + }) +} + +//nolint:golint,unused +func method2Bool(method string) bool { + switch strings.ToUpper(method) { + case "P": + return true + case "G": + return false + default: + panic(fmt.Errorf("invalid method '%s'", method)) + } +} + +//nolint:golint,unused +func copyCommand(cmd *model.Command) *model.Command { + c := *cmd + return &c +} + +func (s *MmctlUnitTestSuite) TestCommandMoveCmd() { + commandArg := "cmd1" + commandArgBogus := "bogus-command-id" + teamArg := "dest-team-id" + teamArgBogus := "bogus-team-id" + + mockTeamDest := model.Team{Id: teamArg} + + mockCommand := model.Command{ + Id: commandArg, + TeamId: "orig-team-id", + DisplayName: "example-title", + Trigger: "example-trigger", + } + + mockError := errors.New("mock error") + outputMessageOK := map[string]interface{}{"status": "ok"} + outputMessageError := map[string]interface{}{"status": "error"} + + s.Run("Move custom slash command to another team by id", func() { + printer.Clean() + mockCommandModified := copyCommand(&mockCommand) + mockCommandModified.TeamId = teamArg + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(&mockTeamDest, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetCommandById(commandArg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + MoveCommand(teamArg, mockCommand.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := moveCommandCmdF(s.client, &cobra.Command{}, []string{teamArg, mockCommand.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], outputMessageOK) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Move custom slash command to invalid team by id", func() { + printer.Clean() + s.client. + EXPECT(). + GetTeam(teamArgBogus, ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetTeamByName(teamArgBogus, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := moveCommandCmdF(s.client, &cobra.Command{}, []string{teamArgBogus, commandArg}) + s.Require().NotNil(err) + s.EqualError(err, "unable to find team '"+teamArgBogus+"'") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Move custom slash command to different team by invalid id", func() { + printer.Clean() + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeamDest, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetCommandById(commandArgBogus). + Return(nil, &model.Response{}, nil). + Times(1) + + err := moveCommandCmdF(s.client, &cobra.Command{}, []string{teamArg, commandArgBogus}) + s.Require().NotNil(err) + s.EqualError(err, "unable to find command '"+commandArgBogus+"'") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Unable to move custom slash command", func() { + printer.Clean() + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeamDest, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetCommandById(commandArg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + MoveCommand(teamArg, commandArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := moveCommandCmdF(s.client, &cobra.Command{}, []string{teamArg, commandArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], outputMessageError) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Move custom slash command with response error", func() { + printer.Clean() + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeamDest, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetCommandById(commandArg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + MoveCommand(teamArg, commandArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := moveCommandCmdF(s.client, &cobra.Command{}, []string{teamArg, commandArg}) + s.Require().NotNil(err) + s.Require().EqualError(err, "unable to move command '"+commandArg+"'. "+mockError.Error()) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestCommandShowCmd() { + commandArg := "example-command-id" + commandArgBogus := "bogus-command-id" + + mockCommand := model.Command{ + Id: commandArg, + TeamId: "example-team-id", + DisplayName: "example-command-name", + Description: "example-description-text", + Trigger: "example-trigger-word", + URL: "http://localhost:8000/example", + CreatorId: "example-user-id", + Username: "example-username2", + IconURL: "http://mydomain/example-icon-url", + Method: "G", + AutoComplete: false, + AutoCompleteDesc: "example autocomplete description", + AutoCompleteHint: "autocompleteHint", + } + mockTeam := model.Team{Id: "mockteamid", Name: "TeamRed"} + + s.Run("Show custom slash command via id", func() { + printer.Clean() + + // showCommandCmdF will look up command by id + s.client. + EXPECT(). + GetCommandById(commandArg). + Return(&mockCommand, &model.Response{}, nil). + Times(1) + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{commandArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Equal(&mockCommand, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Show custom slash command with invalid id", func() { + printer.Clean() + // showCommandCmdF will look up command by id + s.client. + EXPECT(). + GetCommandById(commandArgBogus). + Return(nil, &model.Response{}, nil). + Times(1) + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{commandArgBogus}) + s.Require().NotNil(err) + s.EqualError(err, "unable to find command '"+commandArgBogus+"'") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Show custom slash command via team:trigger", func() { + printer.Clean() + + list := []*model.Command{copyCommand(&mockCommand), &mockCommand, copyCommand(&mockCommand)} + list[0].Trigger = "bloop" + list[2].Trigger = "bleep" + + s.client. + EXPECT(). + GetTeamByName(mockTeam.Name, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ListCommands(mockTeam.Id, false). + Return(list, &model.Response{}, nil). + Times(1) + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{fmt.Sprintf("%s:%s", mockTeam.Name, mockCommand.Trigger)}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Equal(&mockCommand, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Show custom slash command via team:trigger with invalid team", func() { + printer.Clean() + + list := []*model.Command{copyCommand(&mockCommand), &mockCommand, copyCommand(&mockCommand)} + list[0].Trigger = "bloop" + list[2].Trigger = "bleep" + + const teamName = "bogus_team" + teamTrigger := fmt.Sprintf("%s:%s", teamName, mockCommand.Trigger) + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, errors.New("team not found")). + Times(1) + + s.client. + EXPECT(). + GetCommandById(teamTrigger). + Return(nil, &model.Response{}, errors.New("command not found")). + Times(1) + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{teamTrigger}) + s.Require().EqualError(err, fmt.Sprintf("unable to find command '%s'", teamTrigger)) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Show custom slash command via team:trigger with invalid trigger", func() { + printer.Clean() + + list := []*model.Command{copyCommand(&mockCommand), &mockCommand, copyCommand(&mockCommand)} + list[0].Trigger = "bloop" + list[2].Trigger = "bleep" + + const trigger = "bogus_trigger" + teamTrigger := fmt.Sprintf("%s:%s", mockTeam.Name, trigger) + + s.client. + EXPECT(). + GetTeamByName(mockTeam.Name, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ListCommands(mockTeam.Id, false). + Return(list, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetCommandById(teamTrigger). + Return(nil, &model.Response{}, errors.New("bogus")). + Times(1) + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{teamTrigger}) + s.Require().EqualError(err, fmt.Sprintf("unable to find command '%s'", teamTrigger)) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Avoid path traversal", func() { + printer.Clean() + arg := "\"test/../hello?\"move" + + err := showCommandCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().NotNil(err) + s.EqualError(err, "unable to find command '\"test/../hello?\"move'") + }) +} diff --git a/server/cmd/mmctl/commands/commandargs.go b/server/cmd/mmctl/commands/commandargs.go new file mode 100644 index 0000000000..cba0e8474c --- /dev/null +++ b/server/cmd/mmctl/commands/commandargs.go @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "strings" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + + "github.com/mattermost/mattermost-server/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(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(arr[0], "") + if team == nil { + return nil + } + + trigger := arr[1] + if trigger == "" { + return nil + } + + list, _, _ := c.ListCommands(team.Id, false) + if list == nil { + return nil + } + + for _, cmd := range list { + if cmd.Trigger == trigger { + return cmd + } + } + return nil +} diff --git a/server/cmd/mmctl/commands/completion.go b/server/cmd/mmctl/commands/completion.go new file mode 100644 index 0000000000..666cb48311 --- /dev/null +++ b/server/cmd/mmctl/commands/completion.go @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "os" + + "github.com/spf13/cobra" +) + +var CompletionCmd = &cobra.Command{ + Use: "completion", + Short: "Generates autocompletion scripts for bash and zsh", +} + +var BashCmd = &cobra.Command{ + Use: "bash", + Short: "Generates the bash autocompletion scripts", + Long: `To load completion, run + +. <(mmctl completion bash) + +To configure your bash shell to load completions for each session, add the above line to your ~/.bashrc +`, + RunE: bashCmdF, +} + +var ZshCmd = &cobra.Command{ + Use: "zsh", + Short: "Generates the zsh autocompletion scripts", + Long: `To load completion, run + +. <(mmctl completion zsh) + +To configure your zsh shell to load completions for each session, add the above line to your ~/.zshrc +`, + RunE: zshCmdF, +} + +func init() { + CompletionCmd.AddCommand( + BashCmd, + ZshCmd, + ) + + RootCmd.AddCommand(CompletionCmd) +} + +func bashCmdF(cmd *cobra.Command, args []string) error { + return RootCmd.GenBashCompletion(os.Stdout) +} + +func zshCmdF(cmd *cobra.Command, args []string) error { + zshInitialization := ` +__mmctl_bash_source() { + alias shopt=':' + alias _expand=_bash_expand + alias _complete=_bash_comp + emulate -L sh + setopt kshglob noshglob braceexpand + source "$@" +} +__mmctl_type() { + # -t is not supported by zsh + if [ "$1" == "-t" ]; then + shift + # fake Bash 4 to disable "complete -o nospace". Instead + # "compopt +-o nospace" is used in the code to toggle trailing + # spaces. We don't support that, but leave trailing spaces on + # all the time + if [ "$1" = "__mmctl_compopt" ]; then + echo builtin + return 0 + fi + fi + type "$@" +} +__mmctl_compgen() { + local completions w + completions=( $(compgen "$@") ) || return $? + # filter by given word as prefix + while [[ "$1" = -* && "$1" != -- ]]; do + shift + shift + done + if [[ "$1" == -- ]]; then + shift + fi + for w in "${completions[@]}"; do + if [[ "${w}" = "$1"* ]]; then + echo "${w}" + fi + done +} +__mmctl_compopt() { + true # don't do anything. Not supported by bashcompinit in zsh +} +__mmctl_declare() { + if [ "$1" == "-F" ]; then + whence -w "$@" + else + builtin declare "$@" + fi +} +__mmctl_ltrim_colon_completions() +{ + if [[ "$1" == *:* && "$COMP_WORDBREAKS" == *:* ]]; then + # Remove colon-word prefix from COMPREPLY items + local colon_word=${1%${1##*:}} + local i=${#COMPREPLY[*]} + while [[ $((--i)) -ge 0 ]]; do + COMPREPLY[$i]=${COMPREPLY[$i]#"$colon_word"} + done + fi +} +__mmctl_get_comp_words_by_ref() { + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[${COMP_CWORD}-1]}" + words=("${COMP_WORDS[@]}") + cword=("${COMP_CWORD[@]}") +} +__mmctl_filedir() { + local RET OLD_IFS w qw + __debug "_filedir $@ cur=$cur" + if [[ "$1" = \~* ]]; then + # somehow does not work. Maybe, zsh does not call this at all + eval echo "$1" + return 0 + fi + OLD_IFS="$IFS" + IFS=$'\n' + if [ "$1" = "-d" ]; then + shift + RET=( $(compgen -d) ) + else + RET=( $(compgen -f) ) + fi + IFS="$OLD_IFS" + IFS="," __debug "RET=${RET[@]} len=${#RET[@]}" + for w in ${RET[@]}; do + if [[ ! "${w}" = "${cur}"* ]]; then + continue + fi + if eval "[[ \"\${w}\" = *.$1 || -d \"\${w}\" ]]"; then + qw="$(__mmctl_quote "${w}")" + if [ -d "${w}" ]; then + COMPREPLY+=("${qw}/") + else + COMPREPLY+=("${qw}") + fi + fi + done +} +__mmctl_quote() { + if [[ $1 == \'* || $1 == \"* ]]; then + # Leave out first character + printf %q "${1:1}" + else + printf %q "$1" + fi +} +autoload -U +X bashcompinit && bashcompinit +# use word boundary patterns for BSD or GNU sed +LWORD='[[:<:]]' +RWORD='[[:>:]]' +if sed --help 2>&1 | grep -q GNU; then + LWORD='\<' + RWORD='\>' +fi +__mmctl_convert_bash_to_zsh() { + sed \ + -e 's/declare -F/whence -w/' \ + -e 's/local \([a-zA-Z0-9_]*\)=/local \1; \1=/' \ + -e 's/flags+=("\(--.*\)=")/flags+=("\1"); two_word_flags+=("\1")/' \ + -e 's/must_have_one_flag+=("\(--.*\)=")/must_have_one_flag+=("\1")/' \ + -e "s/${LWORD}_filedir${RWORD}/__mmctl_filedir/g" \ + -e "s/${LWORD}_get_comp_words_by_ref${RWORD}/__mmctl_get_comp_words_by_ref/g" \ + -e "s/${LWORD}__ltrim_colon_completions${RWORD}/__mmctl_ltrim_colon_completions/g" \ + -e "s/${LWORD}compgen${RWORD}/__mmctl_compgen/g" \ + -e "s/${LWORD}compopt${RWORD}/__mmctl_compopt/g" \ + -e "s/${LWORD}declare${RWORD}/__mmctl_declare/g" \ + -e "s/\\\$(type${RWORD}/\$(__mmctl_type/g" \ + <<'BASH_COMPLETION_EOF' +` + + zshTail := ` +BASH_COMPLETION_EOF +} +__mmctl_bash_source <(__mmctl_convert_bash_to_zsh) +` + + if _, err := os.Stdout.Write([]byte(zshInitialization)); err != nil { + return err + } + if err := RootCmd.GenBashCompletion(os.Stdout); err != nil { + return err + } + if _, err := os.Stdout.Write([]byte(zshTail)); err != nil { + return err + } + + return nil +} diff --git a/server/cmd/mmctl/commands/config.go b/server/cmd/mmctl/commands/config.go new file mode 100644 index 0000000000..bd15574419 --- /dev/null +++ b/server/cmd/mmctl/commands/config.go @@ -0,0 +1,569 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "reflect" + "strconv" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/utils" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 mysql\nconfig set SqlSettings.DataSourceReplicas \"replica1\" \"replica2\"", + Args: cobra.MinimumNArgs(2), + RunE: withClient(configSetCmdF), +} + +var ConfigPatchCmd = &cobra.Command{ + Use: "patch ", + 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@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, +} + +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") + + ConfigCmd.AddCommand( + ConfigGetCmd, + ConfigSetCmd, + ConfigPatchCmd, + ConfigEditCmd, + ConfigResetCmd, + ConfigShowCmd, + ConfigReloadCmd, + ConfigMigrateCmd, + ConfigSubpathCmd, + ) + RootCmd.AddCommand(ConfigCmd) +} + +func getValue(path []string, obj interface{}) (interface{}, 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.Ptr { + 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 interface{}) error { + switch val.Kind() { + case reflect.Struct: + val.Set(reflect.ValueOf(newValue)) + return nil + case reflect.Slice: + if val.Type().Elem().Kind() != reflect.String { + return errors.New("unsupported type of slice") + } + v := reflect.ValueOf(newValue) + if v.Kind() != reflect.Slice { + return errors.New("target value is of type Array and provided value is not") + } + 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", val.Kind()) + } + 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", val.Kind()) + } + 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 errors.New("target value is of type Bool and provided value is not") + } + val.SetBool(v) + return nil + default: + return errors.New("target value type is not supported") + } +} + +func setValue(path []string, obj reflect.Value, newValue interface{}) 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.Ptr { + 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.Ptr { + 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 interface{}) error { + nv := reflect.ValueOf(newValue) + if nv.Kind() == reflect.Ptr { + 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() + 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() + 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(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 := ioutil.ReadFile(args[0]) + if err != nil { + return err + } + + config, _, err := c.GetConfig() + if err != nil { + return err + } + + if jErr := json.Unmarshal(configBytes, config); jErr != nil { + return jErr + } + + newConfig, _, err := c.PatchConfig(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() + if err != nil { + return err + } + + configBytes, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + file, err := ioutil.TempFile(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 := ioutil.ReadFile(file.Name()) + if err != nil { + return err + } + + if jErr := json.Unmarshal(newConfigBytes, config); jErr != nil { + return jErr + } + + newConfig, _, err := c.UpdateConfig(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() + 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(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() + 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() + 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(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.Ptr { + t = t.Elem() + } + + if t.Kind() != reflect.Struct { + return false + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + 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 +} diff --git a/server/cmd/mmctl/commands/config_e2e_test.go b/server/cmd/mmctl/commands/config_e2e_test.go new file mode 100644 index 0000000000..9b51a0655b --- /dev/null +++ b/server/cmd/mmctl/commands/config_e2e_test.go @@ -0,0 +1,236 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "os" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestConfigResetCmdE2E() { + s.SetupTestHelper().InitBasic() + + 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() + + tmpFile, err := ioutil.TempFile(os.TempDir(), "config_*.json") + s.Require().Nil(err) + + invalidFile, err := ioutil.TempFile(os.TempDir(), "invalid_config_*.json") + s.Require().Nil(err) + + _, err = tmpFile.Write([]byte(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() + + 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.RunForSystemAdminAndLocal("Set config value for a given key", func(c client.Client) { + printer.Clean() + + args := []string{"SqlSettings.DriverName", "mysql"} + 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("mysql", *(config.SqlSettings.DriverName)) + }) + + s.RunForSystemAdminAndLocal("Get error if the key doesn't exists", func(c client.Client) { + printer.Clean() + + args := []string{"SqlSettings.WrongKey", "mysql"} + 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", "mysql"} + err := configSetCmdF(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) TestConfigEditCmd() { + s.SetupTestHelper().InitBasic() + + 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 := ioutil.TempFile(os.TempDir(), "config_edit_*.sh") + s.Require().Nil(err) + defer func() { + os.Remove(file.Name()) + }() + _, err = file.Write([]byte(content)) + s.Require().Nil(err) + s.Require().Nil(file.Close()) + s.Require().Nil(os.Chmod(file.Name(), 0700)) + + os.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.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) + }) +} diff --git a/server/cmd/mmctl/commands/config_test.go b/server/cmd/mmctl/commands/config_test.go new file mode 100644 index 0000000000..9fd1ef4c4b --- /dev/null +++ b/server/cmd/mmctl/commands/config_test.go @@ -0,0 +1,877 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +const ( + configFilePayload = "{\"TeamSettings\": {\"SiteName\": \"ADifferentName\"}}" +) + +func (s *MmctlUnitTestSuite) TestConfigGetCmd() { + s.Run("Get a string config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.DriverName"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("postgres", *(printer.GetLines()[0].(*string))) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get an int config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.MaxIdleConns"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(*(printer.GetLines()[0].(*int)), 20) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get an int64 config value for a given key", func() { + printer.Clean() + args := []string{"FileSettings.MaxFileSize"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(int64(100*(1<<20)), *(printer.GetLines()[0].(*int64))) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get a boolean config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.Trace"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(*(printer.GetLines()[0].(*bool)), false) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get a slice of string config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.DataSourceReplicas"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], []string{}) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get config struct for a given key", func() { + printer.Clean() + args := []string{"SqlSettings"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + sqlSettings := model.SqlSettings{} + sqlSettings.SetDefaults(false) + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], sqlSettings) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get error if the key doesn't exists", func() { + printer.Clean() + args := []string{"SqlSettings.WrongKey"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + sqlSettings := model.SqlSettings{} + sqlSettings.SetDefaults(false) + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should handle the response error", func() { + printer.Clean() + args := []string{"SqlSettings.DriverName"} + outputConfig := &model.Config{} + outputConfig.SetDefaults() + sqlSettings := model.SqlSettings{} + sqlSettings.SetDefaults(false) + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{StatusCode: 500}, errors.New("")). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get value if the key points to a map element", func() { + outputConfig := &model.Config{} + pluginState := &model.PluginState{Enable: true} + pluginSettings := map[string]interface{}{ + "test1": 1, + "test2": []string{"a", "b"}, + "test3": map[string]string{"a": "b"}, + } + outputConfig.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.testplugin": pluginState, + } + outputConfig.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.testplugin": pluginSettings, + } + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(7) + + printer.Clean() + err := configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.PluginStates.com.mattermost.testplugin"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], pluginState) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], pluginSettings) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test1"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], 1) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test2"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], []string{"a", "b"}) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test3"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], map[string]string{"a": "b"}) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configGetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test3.a"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "b") + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get error value if the key points to a missing map element", func() { + printer.Clean() + args := []string{"PluginSettings.PluginStates.com.mattermost.testplugin.x"} + outputConfig := &model.Config{} + pluginState := &model.PluginState{Enable: true} + outputConfig.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.testplugin": pluginState, + } + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(0) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Get cloud restricted error value if the path is cloud restricted and value is nil", func() { + printer.Clean() + args := []string{"ServiceSettings.EnableDeveloper"} + outputConfig := &model.Config{} + + s.client. + EXPECT(). + GetConfig(). + Return(outputConfig, &model.Response{}, nil). + Times(1) + + err := configGetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigSetCmd() { + s.Run("Set a string config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.DriverName", "postgres"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := "postgres" + inputConfig.SqlSettings.DriverName = &changedValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set an int config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.MaxIdleConns", "20"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := 20 + inputConfig.SqlSettings.MaxIdleConns = &changedValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set an int64 config value for a given key", func() { + printer.Clean() + args := []string{"FileSettings.MaxFileSize", "52428800"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := int64(52428800) + inputConfig.FileSettings.MaxFileSize = &changedValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set a boolean config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.Trace", "true"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := true + inputConfig.SqlSettings.Trace = &changedValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set a slice of string config value for a given key", func() { + printer.Clean() + args := []string{"SqlSettings.DataSourceReplicas", "test1", "test2"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + inputConfig.SqlSettings.DataSourceReplicas = []string{"test1", "test2"} + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should get an error if a string is passed while trying to set a slice", func() { + printer.Clean() + args := []string{"SqlSettings.DataSourceReplicas", "[\"test1\", \"test2\"]"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + inputConfig.SqlSettings.DataSourceReplicas = []string{"test1", "test2"} + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Get error if the key doesn't exists", func() { + printer.Clean() + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + args := []string{"SqlSettings.WrongKey", "test1"} + inputConfig := &model.Config{} + inputConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should handle response error from the server", func() { + printer.Clean() + args := []string{"SqlSettings.DriverName", "postgres"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := "postgres" + inputConfig.SqlSettings.DriverName = &changedValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{StatusCode: 500}, errors.New("")). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set a field inside a map", func() { + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + defaultConfig.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.testplugin": {Enable: false}, + } + pluginSettings := map[string]interface{}{ + "test1": 1, + "test2": []string{"a", "b"}, + "test3": map[string]interface{}{"a": "b"}, + } + defaultConfig.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.testplugin": pluginSettings, + } + + inputConfig := &model.Config{} + inputConfig.SetDefaults() + inputConfig.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.testplugin": {Enable: true}, + } + inputConfig.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.testplugin": pluginSettings, + } + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(3) + + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(3) + + printer.Clean() + err := configSetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.PluginStates.com.mattermost.testplugin.Enable", "true"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configSetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test1", "123"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + + printer.Clean() + err = configSetCmdF(s.client, &cobra.Command{}, []string{"PluginSettings.Plugins.com.mattermost.testplugin.test3.a", "123"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to set a field inside a map for incorrect field, get error", func() { + printer.Clean() + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + defaultConfig.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.testplugin": {Enable: true}, + } + args := []string{"PluginSettings.PluginStates.com.mattermost.testplugin.x", "true"} + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + err := configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Set a config value for a cloud restricted config path", func() { + printer.Clean() + args := []string{"ServiceSettings.EnableDeveloper", "true"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + js, err := defaultConfig.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable) + s.Require().NoError(err) + defaultConfig = model.ConfigFromJSON(bytes.NewBuffer(js)) + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + err = configSetCmdF(s.client, &cobra.Command{}, args) + s.Require().EqualError(err, fmt.Sprintf("changing this config path: %s is restricted in a cloud environment", "ServiceSettings.EnableDeveloper")) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigPatchCmd() { + tmpFile, err := ioutil.TempFile(os.TempDir(), "config_*.json") + s.Require().Nil(err) + + invalidFile, err := ioutil.TempFile(os.TempDir(), "invalid_config_*.json") + s.Require().Nil(err) + + _, err = tmpFile.Write([]byte(configFilePayload)) + s.Require().Nil(err) + + defer func() { + os.Remove(tmpFile.Name()) + os.Remove(invalidFile.Name()) + }() + + s.Run("Patch config with a valid file", func() { + printer.Clean() + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + brandValue := "BrandText" + defaultConfig.TeamSettings.CustomBrandText = &brandValue + + inputConfig := &model.Config{} + inputConfig.SetDefaults() + changedValue := "ADifferentName" + inputConfig.TeamSettings.SiteName = &changedValue + inputConfig.TeamSettings.CustomBrandText = &brandValue + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchConfig(inputConfig). + Return(inputConfig, &model.Response{}, nil). + Times(1) + + err = configPatchCmdF(s.client, &cobra.Command{}, []string{tmpFile.Name()}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], inputConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Fail to patch config if file is invalid", func() { + printer.Clean() + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + err = configPatchCmdF(s.client, &cobra.Command{}, []string{invalidFile.Name()}) + s.Require().NotNil(err) + }) + + s.Run("Fail to patch config if file not found", func() { + printer.Clean() + path := "/path/to/nonexistentfile" + errMsg := "open " + path + ": no such file or directory" + + err = configPatchCmdF(s.client, &cobra.Command{}, []string{path}) + s.Require().NotNil(err) + s.Require().EqualError(err, errMsg) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigResetCmd() { + s.Run("Reset a single key", func() { + printer.Clean() + args := []string{"SqlSettings.DriverName"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + UpdateConfig(defaultConfig). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + resetCmd := &cobra.Command{} + resetCmd.Flags().Bool("confirm", true, "") + err := configResetCmdF(s.client, resetCmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], defaultConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reset a whole config section", func() { + printer.Clean() + args := []string{"SqlSettings"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + UpdateConfig(defaultConfig). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + resetCmd := &cobra.Command{} + resetCmd.Flags().Bool("confirm", true, "") + _ = resetCmd.ParseFlags([]string{"confirm"}) + err := configResetCmdF(s.client, resetCmd, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], defaultConfig) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should fail if the key doesn't exists", func() { + printer.Clean() + args := []string{"WrongKey"} + defaultConfig := &model.Config{} + defaultConfig.SetDefaults() + + s.client. + EXPECT(). + GetConfig(). + Return(defaultConfig, &model.Response{}, nil). + Times(1) + + resetCmd := &cobra.Command{} + resetCmd.Flags().Bool("confirm", true, "") + _ = resetCmd.ParseFlags([]string{"confirm"}) + err := configResetCmdF(s.client, resetCmd, args) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigShowCmd() { + s.Run("Should show config", func() { + printer.Clean() + mockConfig := &model.Config{} + + s.client. + EXPECT(). + GetConfig(). + Return(mockConfig, &model.Response{}, nil). + Times(1) + + err := configShowCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal(mockConfig, printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should return an error", func() { + printer.Clean() + configError := errors.New("config error") + + s.client. + EXPECT(). + GetConfig(). + Return(nil, &model.Response{}, configError). + Times(1) + + err := configShowCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NotNil(err) + s.EqualError(err, configError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigReloadCmd() { + s.Run("Should reload config", func() { + printer.Clean() + + s.client. + EXPECT(). + ReloadConfig(). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := configReloadCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should fail on error when reload config", func() { + printer.Clean() + + s.client. + EXPECT(). + ReloadConfig(). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := configReloadCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NotNil(err) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigMigrateCmd() { + s.Run("Should fail without the --local flag", func() { + printer.Clean() + args := []string{"from", "to"} + + err := configMigrateCmdF(s.client, &cobra.Command{}, args) + s.Require().Error(err) + }) + + s.Run("Should be able to migrate config", func() { + printer.Clean() + args := []string{"from", "to"} + + s.client. + EXPECT(). + MigrateConfig(args[0], args[1]). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("local", true, "") + + err := configMigrateCmdF(s.client, cmd, args) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Should fail on error when migrating config", func() { + printer.Clean() + args := []string{"from", "to"} + + s.client. + EXPECT(). + MigrateConfig(args[0], args[1]). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("local", true, "") + + err := configMigrateCmdF(s.client, cmd, args) + s.Require().NotNil(err) + }) +} + +func TestCloudRestricted(t *testing.T) { + cfg := &model.Config{ + ServiceSettings: model.ServiceSettings{ + GoogleDeveloperKey: model.NewString("test"), + SiteURL: model.NewString("test"), + }, + } + + t.Run("Should return true if the config is cloud restricted", func(t *testing.T) { + path := "ServiceSettings.GoogleDeveloperKey" + + require.True(t, cloudRestricted(cfg, parseConfigPath(path))) + }) + + t.Run("Should return false if the config is not cloud restricted", func(t *testing.T) { + path := "ServiceSettings.SiteURL" + + require.False(t, cloudRestricted(cfg, parseConfigPath(path))) + }) + + t.Run("Should return false if the config is not cloud restricted and the path is not found", func(t *testing.T) { + path := "ServiceSettings.Unknown" + + require.False(t, cloudRestricted(cfg, parseConfigPath(path))) + }) + + t.Run("Should return true if the config is cloud restricted and the value is not found", func(t *testing.T) { + path := "ServiceSettings.EnableDeveloper" + + require.True(t, cloudRestricted(cfg, parseConfigPath(path))) + }) +} diff --git a/server/cmd/mmctl/commands/docs.go b/server/cmd/mmctl/commands/docs.go new file mode 100644 index 0000000000..ce15bc5f5e --- /dev/null +++ b/server/cmd/mmctl/commands/docs.go @@ -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(fmt.Sprintf("File \"%s\" is not a directory", outDir)) + } + + err = doc.GenReSTTree(RootCmd, outDir) + if err != nil { + return err + } + + return nil +} diff --git a/server/cmd/mmctl/commands/enterprise.go b/server/cmd/mmctl/commands/enterprise.go new file mode 100644 index 0000000000..a401d6d10a --- /dev/null +++ b/server/cmd/mmctl/commands/enterprise.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build enterprise +// +build enterprise + +package commands + +import ( + // Enterprise Deps + _ "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/server/v8/channels/imports" + _ "github.com/mattermost/mattermost-server/server/v8/channels/utils/testutils" + _ "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" + _ "gopkg.in/olivere/elastic.v6" +) diff --git a/server/cmd/mmctl/commands/errors.go b/server/cmd/mmctl/commands/errors.go new file mode 100644 index 0000000000..e5c180ac5d --- /dev/null +++ b/server/cmd/mmctl/commands/errors.go @@ -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/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 + } +} diff --git a/server/cmd/mmctl/commands/export.go b/server/cmd/mmctl/commands/export.go new file mode 100644 index 0000000000..0104fef4ef --- /dev/null +++ b/server/cmd/mmctl/commands/export.go @@ -0,0 +1,255 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "io" + "os" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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 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("attachments", false, "Set to true to include file attachments in the export file.") + _ = ExportCreateCmd.Flags().MarkHidden("attachments") + _ = ExportCreateCmd.Flags().MarkDeprecated("attachments", "the tool now includes attachments by default. The flag will be removed in a future version.") + + ExportCreateCmd.Flags().Bool("no-attachments", false, "Set to true to exclude file attachments in the export file.") + + ExportDownloadCmd.Flags().Bool("resume", false, "Set to true to resume an export download.") + _ = ExportDownloadCmd.Flags().MarkHidden("resume") + // Intentionally the message does not start with a capital letter because + // cobra prepends "Flag --resume has been deprecated," + _ = ExportDownloadCmd.Flags().MarkDeprecated("resume", "the tool now resumes a download automatically. The flag will be removed in a future version.") + 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", 200, "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, + 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" + } + + job, _, err := c.CreateJob(&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() + 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(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 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") + + var outFile *os.File + 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 export file: %w", err) + case err == nil && info.Size() > 0: + // we exit to avoid overwriting an existing non-empty file + return fmt.Errorf("export file already exists") + case err != nil: + // file does not exist, we create it + outFile, err = os.Create(path) + default: + // no error, file exists, we open it + outFile, err = os.OpenFile(path, os.O_WRONLY, 0600) + } + + if err != nil { + return fmt.Errorf("failed to create/open export file: %w", err) + } + defer outFile.Close() + + i := 0 + for i < retries+1 { + off, err := outFile.Seek(0, io.SeekEnd) + if err != nil { + return fmt.Errorf("failed to seek export file: %w", err) + } + + if _, _, err := c.DownloadExport(name, outFile, off); err != nil { + printer.PrintWarning(fmt.Sprintf("failed to download export file: %v. Retrying...", err)) + i++ + continue + } + break + } + + if retries != 0 && i == retries+1 { + return fmt.Errorf("failed to download export after %d retries", retries) + } + + return 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(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(args[0]) + if err != nil { + return fmt.Errorf("failed to get export job: %w", err) + } + + if _, err := c.CancelJob(job.Id); err != nil { + return fmt.Errorf("failed to cancel export job: %w", err) + } + + return nil +} diff --git a/server/cmd/mmctl/commands/export_e2e_test.go b/server/cmd/mmctl/commands/export_e2e_test.go new file mode 100644 index 0000000000..5a2de1ea22 --- /dev/null +++ b/server/cmd/mmctl/commands/export_e2e_test.go @@ -0,0 +1,452 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/public/utils" + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestExportListCmdF() { + s.SetupTestHelper() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "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 := 0; i < numExports; i++ { + 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() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "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.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().Empty(printer.GetLines()[0].(*model.Job).Data) + }) +} + +func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() { + s.SetupTestHelper() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "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") + 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") + 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().Empty(printer.GetLines()) + s.Require().Empty(printer.GetErrorLines()) + }) + + 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().Empty(printer.GetLines()) + s.Require().Empty(printer.GetErrorLines()) + + expected, err := ioutil.ReadFile(exportFilePath) + s.Require().Nil(err) + actual, err := ioutil.ReadFile(downloadPath) + s.Require().Nil(err) + + s.Require().Equal(expected, actual) + }) +} + +func (s *MmctlE2ETestSuite) TestExportJobShowCmdF() { + s.SetupTestHelper().InitBasic() + + job, appErr := s.th.App.CreateJob(&model.Job{ + Type: model.JobTypeExportProcess, + }) + s.Require().Nil(appErr) + + s.Run("MM-T3885 - no permissions", func() { + printer.Clean() + + job1, appErr := s.th.App.CreateJob(&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.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(&model.Job{ + Type: model.JobTypeExportProcess, + }) + s.Require().Nil(appErr) + + time.Sleep(time.Millisecond) + + job2, appErr := s.th.App.CreateJob(&model.Job{ + Type: model.JobTypeExportProcess, + }) + s.Require().Nil(appErr) + + time.Sleep(time.Millisecond) + + job3, appErr := s.th.App.CreateJob(&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.Run("Cancel an export job without permissions", func() { + printer.Clean() + + cmd := &cobra.Command{} + + job, appErr := s.th.App.CreateJob(&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(&model.Job{ + Type: model.JobTypeExportProcess, + }) + s.Require().Nil(appErr) + + time.Sleep(time.Millisecond) + + job2, appErr := s.th.App.CreateJob(&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(job1.Id) + s.Require().Nil(appErr) + + // Get job2 again to ensure its status did not change + job2, _ = s.th.App.GetJob(job2.Id) + s.Require().Nil(appErr) + + s.Require().Equal(job1.Status, model.JobStatusCanceled) + s.Require().NotEqual(job2.Status, model.JobStatusCanceled) + }) +} diff --git a/server/cmd/mmctl/commands/export_test.go b/server/cmd/mmctl/commands/export_test.go new file mode 100644 index 0000000000..2567ea9f76 --- /dev/null +++ b/server/cmd/mmctl/commands/export_test.go @@ -0,0 +1,119 @@ +// 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/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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"}, + } + + s.client. + EXPECT(). + CreateJob(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: make(map[string]string), + } + + s.client. + EXPECT(). + CreateJob(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)) + }) +} + +func (s *MmctlUnitTestSuite) TestExportDeleteCmdF() { + printer.Clean() + + exportName := "export.zip" + + s.client. + EXPECT(). + DeleteExport(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(). + 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(). + 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) + } + }) +} diff --git a/server/cmd/mmctl/commands/extract.go b/server/cmd/mmctl/commands/extract.go new file mode 100644 index 0000000000..cca2cc335e --- /dev/null +++ b/server/cmd/mmctl/commands/extract.go @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "strconv" + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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", 200, "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(&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(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) + } +} diff --git a/server/cmd/mmctl/commands/extract_e2e_test.go b/server/cmd/mmctl/commands/extract_e2e_test.go new file mode 100644 index 0000000000..7383a0a562 --- /dev/null +++ b/server/cmd/mmctl/commands/extract_e2e_test.go @@ -0,0 +1,187 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "os" + "path/filepath" + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestExtractRunCmdF() { + s.SetupTestHelper().InitBasic() + serverPath := os.Getenv("MM_SERVER_PATH") + docName := "sample-doc.pdf" + docFilePath := filepath.Join(serverPath, "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(&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(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() + + job, appErr := s.th.App.CreateJob(&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(&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.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(&model.Job{ + Type: model.JobTypeExtractContent, + Data: map[string]string{}, + }) + s.Require().Nil(appErr) + + time.Sleep(time.Millisecond) + + job2, appErr := s.th.App.CreateJob(&model.Job{ + Type: model.JobTypeExtractContent, + Data: map[string]string{}, + }) + s.Require().Nil(appErr) + + time.Sleep(time.Millisecond) + + job3, appErr := s.th.App.CreateJob(&model.Job{ + Type: model.JobTypeExtractContent, + Data: map[string]string{}, + }) + s.Require().Nil(appErr) + + 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)) + }) +} diff --git a/server/cmd/mmctl/commands/group.go b/server/cmd/mmctl/commands/group.go new file mode 100644 index 0000000000..eab857ef88 --- /dev/null +++ b/server/cmd/mmctl/commands/group.go @@ -0,0 +1,345 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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() + 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(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: model.NewBool(true)} + if _, _, err = c.PatchChannel(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: model.NewBool(false)} + if _, _, err := c.PatchChannel(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(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(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: model.NewBool(true)} + if _, _, err = c.PatchTeam(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: model.NewBool(false)} + if _, _, err := c.PatchTeam(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(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(groupID, "") + if err != nil { + return err + } + + if resp.StatusCode == http.StatusOK { + printer.Print("Group successfully restored with ID: " + groupID) + } + + return nil +} diff --git a/server/cmd/mmctl/commands/group_e2e_test.go b/server/cmd/mmctl/commands/group_e2e_test.go new file mode 100644 index 0000000000..ca1ab6cb99 --- /dev/null +++ b/server/cmd/mmctl/commands/group_e2e_test.go @@ -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/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestChannelGroupEnableCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + 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: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewString(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 = model.NewBool(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() + + 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: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewString(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 = model.NewBool(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 = model.NewBool(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() + 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() + + 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: model.NewBool(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() + + 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: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewString(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() + + team, _, cleanUpFn := createTestGroupTeam(s) + defer cleanUpFn() + + team.GroupConstrained = model.NewBool(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 = model.NewBool(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() + + 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 = model.NewBool(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() + + 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() + + 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: model.NewBool(true), + }) + s.Require().Nil(appErr) + + id := model.NewId() + group, appErr := s.th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewString(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() + + // create group + id := model.NewId() + group, appErr := s.th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceCustom, + Description: "description_" + id, + RemoteId: model.NewString(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(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(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) + }) +} diff --git a/server/cmd/mmctl/commands/group_test.go b/server/cmd/mmctl/commands/group_test.go new file mode 100644 index 0000000000..17bdaa5cc2 --- /dev/null +++ b/server/cmd/mmctl/commands/group_test.go @@ -0,0 +1,1578 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestListLdapGroupsCmd() { + s.Run("Failure getting Ldap Groups", func() { + printer.Clean() + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetLdapGroups(). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := listLdapGroupsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Equal(mockError, err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("List several groups", func() { + printer.Clean() + mockList := []*model.Group{ + {DisplayName: "Group1"}, + {DisplayName: "Group2"}, + {DisplayName: "Group3"}, + } + + s.client. + EXPECT(). + GetLdapGroups(). + Return(mockList, &model.Response{}, nil). + Times(1) + + err := listLdapGroupsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 3) + for i, v := range mockList { + s.Require().Equal(v, printer.GetLines()[i]) + } + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestTeamGroupEnableCmd() { + s.Run("Enable unexisting team", func() { + printer.Clean() + + arg := "teamID" + + s.client. + EXPECT(). + GetTeam(arg, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(arg, ""). + Return(nil, &model.Response{}, errors.New("")). + Times(1) + + err := teamGroupEnableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().EqualError(err, "Unable to find team '"+arg+"'") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error while getting the team groups", func() { + printer.Clean() + + arg := "teamID" + mockTeam := model.Team{Id: arg} + mockError := errors.New("mock error") + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(arg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam(mockTeam.Id, groupOpts). + Return(nil, 0, &model.Response{}, mockError). + Times(1) + + err := teamGroupEnableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Equal(mockError, err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("No groups on team", func() { + printer.Clean() + + arg := "teamID" + mockTeam := model.Team{Id: arg} + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(arg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam(mockTeam.Id, groupOpts). + Return([]*model.GroupWithSchemeAdmin{}, 0, &model.Response{}, nil). + Times(1) + + err := teamGroupEnableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().EqualError(err, "Team '"+arg+"' has no groups associated. It cannot be group-constrained") + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error patching the team", func() { + printer.Clean() + + arg := "teamID" + mockTeam := model.Team{Id: arg} + mockError := errors.New("mock error") + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + teamPatch := model.TeamPatch{GroupConstrained: model.NewBool(true)} + + s.client. + EXPECT(). + GetTeam(arg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam(mockTeam.Id, groupOpts). + Return([]*model.GroupWithSchemeAdmin{{}}, 1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchTeam(mockTeam.Id, &teamPatch). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := teamGroupEnableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Equal(mockError, err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Successfully enable group", func() { + printer.Clean() + + arg := "teamID" + mockTeam := model.Team{Id: arg} + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + teamPatch := model.TeamPatch{GroupConstrained: model.NewBool(true)} + + s.client. + EXPECT(). + GetTeam(arg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam(mockTeam.Id, groupOpts). + Return([]*model.GroupWithSchemeAdmin{{}}, 1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchTeam(mockTeam.Id, &teamPatch). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + err := teamGroupEnableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().NoError(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestTeamGroupDisableCmd() { + s.Run("Disable existing team", func() { + printer.Clean() + teamArg := "example-team-id" + mockTeam := model.Team{Id: teamArg} + teamPatch := model.TeamPatch{GroupConstrained: model.NewBool(false)} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchTeam(teamArg, &teamPatch). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamGroupDisableCmdF(s.client, &cobra.Command{}, []string{teamArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + }) + + s.Run("Disable nonexisting team", func() { + printer.Clean() + teamArg := "example-team-id" + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamGroupDisableCmdF(s.client, &cobra.Command{}, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find team '"+teamArg+"'") + }) + + s.Run("Error response from PatchTeam", func() { + printer.Clean() + teamArg := "example-team-id" + mockTeam := model.Team{Id: teamArg} + teamPatch := model.TeamPatch{GroupConstrained: model.NewBool(false)} + mockError := errors.New("patchteam error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchTeam(teamArg, &teamPatch). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := teamGroupDisableCmdF(s.client, &cobra.Command{}, []string{teamArg}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.EqualError(err, mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestChannelGroupListCmd() { + s.Run("List groups for existing channel and team, when a single group exists", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + groupName := "group-name" + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID} + mockGroup := &model.GroupWithSchemeAdmin{Group: model.Group{Name: model.NewString(groupName)}} + mockGroups := []*model.GroupWithSchemeAdmin{mockGroup} + + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelID, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], mockGroup) + }) + + s.Run("List groups for existing channel and team, when multiple groups exist", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID} + mockGroups := []*model.GroupWithSchemeAdmin{ + {Group: model.Group{Name: model.NewString("group1")}}, + {Group: model.Group{Name: model.NewString("group2")}}, + } + + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelID, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], mockGroups[0]) + s.Require().Equal(printer.GetLines()[1], mockGroups[1]) + }) + + s.Run("List groups for existing channel and team, when no groups exist", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID} + mockGroups := []*model.GroupWithSchemeAdmin{} + + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelID, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("List groups for a nonexistent channel", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockTeam := model.Team{Id: teamID} + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().NotNil(err) + s.EqualError(err, "Unable to find channel '"+cmdArg+"'") + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("List groups for a nonexistent team", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().NotNil(err) + s.EqualError(err, "Unable to find channel '"+cmdArg+"'") + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when GetGroupsByChannel returns error", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockTeam := model.Team{Id: teamID} + mockChannel := model.Channel{Id: channelID} + mockError := errors.New("mock error") + + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelID, *groupOpts). + Return(nil, 0, &model.Response{}, mockError). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.Require().Equal(err, mockError) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when GetChannelByNameIncludeDeleted returns error", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockTeam := model.Team{Id: teamID} + mockError := errors.New("mock error") + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.EqualError(err, "Unable to find channel '"+cmdArg+"'") + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return error when GetTeam returns error", func() { + printer.Clean() + + teamID := "team-id" + channelID := "channel-id" + + mockError := errors.New("mock error") + + cmdArg := teamID + ":" + channelID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupListCmdF(s.client, &cobra.Command{}, []string{cmdArg}) + s.EqualError(err, "Unable to find channel '"+cmdArg+"'") + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestTeamGroupListCmd() { + s.Run("Team group list returns error when passing a nonexistent team", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName("team1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + err := teamGroupListCmdF(s.client, cmd, []string{"team1"}) + + s.Require().NotNil(err) + s.Require().Equal(err.Error(), "Unable to find team 'team1'") + }) + + s.Run("Team group list return error when GetGroupsByTeam returns error", func() { + printer.Clean() + groupID := "group1" + groupID2 := "group2" + mockError := errors.New("get groups by team error") + + group1 := model.GroupWithSchemeAdmin{Group: model.Group{Id: groupID, DisplayName: "DisplayName1"}} + group2 := model.GroupWithSchemeAdmin{Group: model.Group{Id: groupID2, DisplayName: "DisplayName2"}} + + groups := []*model.GroupWithSchemeAdmin{ + &group1, + &group2, + } + + mockTeam := model.Team{Id: "team1"} + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam("team1", groupOpts). + Return(groups, 2, &model.Response{}, mockError). + Times(1) + + cmd := &cobra.Command{} + err := teamGroupListCmdF(s.client, cmd, []string{"team1"}) + + s.Require().NotNil(err) + s.Require().Equal(err, mockError) + }) + + s.Run("Team group list should print group in console on success", func() { + printer.Clean() + groupID := "group1" + groupID2 := "group2" + group1 := model.GroupWithSchemeAdmin{Group: model.Group{Id: groupID, DisplayName: "DisplayName1"}} + group2 := model.GroupWithSchemeAdmin{Group: model.Group{Id: groupID2, DisplayName: "DisplayName2"}} + + groups := []*model.GroupWithSchemeAdmin{ + &group1, + &group2, + } + + mockTeam := model.Team{Id: "team1"} + groupOpts := model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 9999, + }, + } + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByTeam("team1", groupOpts). + Return(groups, 2, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + err := teamGroupListCmdF(s.client, cmd, []string{"team1"}) + + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], &group1) + s.Require().Equal(printer.GetLines()[1], &group2) + }) +} + +func (s *MmctlUnitTestSuite) TestTeamGroupStatusCmd() { + s.Run("Should fail when team is not found", func() { + printer.Clean() + + teamID := "teamID" + arg := teamID + args := []string{arg} + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamGroupStatusCmdF(s.client, cmd, args) + + s.Require().EqualError(err, "Unable to find team '"+args[0]+"'") + }) + + s.Run("Should show valid response when group constraints status for a team is not present", func() { + printer.Clean() + + teamID := "teamID" + arg := teamID + args := []string{arg} + cmd := &cobra.Command{} + team := &model.Team{Id: teamID} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + err := teamGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Disabled") + }) + + s.Run("Should show valid response when group constraints status for a team is enabled", func() { + printer.Clean() + + teamID := "teamID" + arg := teamID + args := []string{arg} + cmd := &cobra.Command{} + team := &model.Team{Id: teamID, GroupConstrained: model.NewBool(true)} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + err := teamGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Enabled") + }) + + s.Run("Should show valid response when group constraints status for a team is disabled", func() { + printer.Clean() + + teamID := "teamID" + arg := teamID + args := []string{arg} + cmd := &cobra.Command{} + team := &model.Team{Id: teamID, GroupConstrained: model.NewBool(false)} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + err := teamGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Disabled") + }) +} + +func (s *MmctlUnitTestSuite) TestChannelGroupStatusCmd() { + s.Run("Should fail to get group constrain status of a channel when team is not found", func() { + printer.Clean() + + teamID := "teamID" + channelID := "channelID" + arg := strings.Join([]string{teamID, channelID}, ":") + args := []string{arg} + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupStatusCmdF(s.client, cmd, args) + + s.Require().EqualError(err, "Unable to find channel '"+args[0]+"'") + }) + + s.Run("Should fail to get group constrain status of a channel when channel is not found", func() { + printer.Clean() + + teamID := "teamID" + channelID := "channelID" + arg := strings.Join([]string{teamID, channelID}, ":") + args := []string{arg} + cmd := &cobra.Command{} + + team := &model.Team{Id: teamID} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupStatusCmdF(s.client, cmd, args) + + s.Require().EqualError(err, "Unable to find channel '"+args[0]+"'") + }) + + s.Run("Should get valid response when channel's group constrain status is enabled", func() { + printer.Clean() + + teamID := "teamID" + channelID := "channelID" + arg := strings.Join([]string{teamID, channelID}, ":") + args := []string{arg} + cmd := &cobra.Command{} + + team := &model.Team{Id: teamID} + channel := &model.Channel{Id: channelID, GroupConstrained: model.NewBool(true)} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(channel, &model.Response{}, nil). + Times(1) + + err := channelGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Enabled") + }) + + s.Run("Should get valid response when channel's group constrain status is disabled", func() { + printer.Clean() + + teamID := "teamID" + channelID := "channelID" + arg := strings.Join([]string{teamID, channelID}, ":") + args := []string{arg} + cmd := &cobra.Command{} + + team := &model.Team{Id: teamID} + channel := &model.Channel{Id: channelID, GroupConstrained: model.NewBool(false)} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(channel, &model.Response{}, nil). + Times(1) + + err := channelGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Disabled") + }) + + s.Run("Should get valid response when channel's group constrain status is not present", func() { + printer.Clean() + + teamID := "teamID" + channelID := "channelID" + arg := strings.Join([]string{teamID, channelID}, ":") + args := []string{arg} + cmd := &cobra.Command{} + + team := &model.Team{Id: teamID} + channel := &model.Channel{Id: channelID} + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(team, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelID, teamID, ""). + Return(channel, &model.Response{}, nil). + Times(1) + + err := channelGroupStatusCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Disabled") + }) +} + +func (s *MmctlUnitTestSuite) TestChannelGroupEnableCmdF() { + s.Run("Enable group constraints with existing team and channel", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + group := &model.GroupWithSchemeAdmin{Group: model.Group{Name: model.NewString("group-name")}} + mockGroups := []*model.GroupWithSchemeAdmin{group} + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelPart, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(true)}). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Enable group constraints with GetTeam error", func() { + printer.Clean() + + teamArg := "team-id" + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Enable group constraints with GetChannelByNameIncludeDeleted error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelPart, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Enable group constraints with GetGroupsByChannel error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelPart, *groupOpts). + Return(nil, 0, &model.Response{}, mockError). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, mockError.Error()) + }) + + s.Run("Enable group constraints with PatchChannel error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + group := &model.GroupWithSchemeAdmin{Group: model.Group{Name: model.NewString("group-name")}} + mockGroups := []*model.GroupWithSchemeAdmin{group} + mockError := errors.New("mock error") + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelPart, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(true)}). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, mockError.Error()) + }) + + s.Run("Enable group constraints with no associated groups", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + mockGroups := []*model.GroupWithSchemeAdmin{} + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelPart, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Channel '"+channelArg+"' has no groups associated. It cannot be group-constrained") + }) + + s.Run("Enable group constraints with nonexistent team", func() { + printer.Clean() + + teamArg := "team-id" + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Enable group constraints with nonexistent channel", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelPart, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Enable group constraints with GetChannelByNameIncludeDeleted error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + group := &model.GroupWithSchemeAdmin{Group: model.Group{Name: model.NewString("group-name")}} + mockGroups := []*model.GroupWithSchemeAdmin{group} + mockError := errors.New("mock error") + groupOpts := &model.GroupSearchOpts{ + PageOpts: &model.PageOpts{ + Page: 0, + PerPage: 10, + }, + } + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelPart, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetGroupsByChannel(channelPart, *groupOpts). + Return(mockGroups, 0, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(true)}). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := channelGroupEnableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestChannelGroupDisableCmdF() { + s.Run("Disable group constraints with existing team and channel", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := strings.Join([]string{teamArg, channelPart}, ":") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(false)}). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Disable group constraints with nonexistent team", func() { + printer.Clean() + + teamArg := "team-id" + channelPart := "channel-id" + channelArg := strings.Join([]string{teamArg, channelPart}, ":") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Disable group constraints with nonexistent channel", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + channelArg := strings.Join([]string{teamArg, channelPart}, ":") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelPart, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Disable group constraints with GetTeam error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(false)}). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Disable group constraints with GetTeamByName error", func() { + printer.Clean() + + teamArg := "team-id" + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Disable group constraints with GetChannelByNameIncludeDeleted error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetChannel(channelPart, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, "Unable to find channel '"+channelArg+"'") + }) + + s.Run("Disable group constraints with PatchChannel error", func() { + printer.Clean() + + teamArg := "team-id" + mockTeam := model.Team{Id: teamArg} + channelPart := "channel-id" + mockChannel := model.Channel{Id: channelPart} + channelArg := teamArg + ":" + channelPart + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetChannelByNameIncludeDeleted(channelPart, teamArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchChannel(channelPart, &model.ChannelPatch{GroupConstrained: model.NewBool(false)}). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := channelGroupDisableCmdF(s.client, &cobra.Command{}, []string{channelArg}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.EqualError(err, mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestUserGroupRestoreCmd() { + s.Run("User group restore command restores deleted user group", func() { + printer.Clean() + + s.client. + EXPECT(). + RestoreGroup("groupId", ""). + Return(nil, &model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + err := userGroupRestoreCmdF(s.client, cmd, []string{"groupId"}) + + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + }) + + s.Run("User group restore command restores deleted user group", func() { + printer.Clean() + + mockError := errors.New("no group found") + s.client. + EXPECT(). + RestoreGroup("groupId", ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, mockError). + Times(1) + + cmd := &cobra.Command{} + err := userGroupRestoreCmdF(s.client, cmd, []string{"groupId"}) + + s.Require().NotNil(err) + s.Require().Equal(mockError, err) + }) +} diff --git a/server/cmd/mmctl/commands/import.go b/server/cmd/mmctl/commands/import.go new file mode 100644 index 0000000000..ea91a90aca --- /dev/null +++ b/server/cmd/mmctl/commands/import.go @@ -0,0 +1,521 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "text/template" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/commands/importer" + "github.com/mattermost/mattermost-server/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 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: importValidateCmdF, +} + +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", 200, "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") + + ImportListCmd.AddCommand( + ImportListAvailableCmd, + ImportListIncompleteCmd, + ) + ImportJobCmd.AddCommand( + ImportJobListCmd, + ImportJobShowCmd, + ) + ImportCmd.AddCommand( + ImportUploadCmd, + ImportListCmd, + ImportProcessCmd, + ImportJobCmd, + ImportValidateCmd, + ) + 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(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() + 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] + + 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(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(&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(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 importProcessCmdF(c client.Client, command *cobra.Command, args []string) error { + importFile := args[0] + + job, _, err := c.CreateJob(&model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": importFile, + }, + }) + 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 printJob(job *model.Job) { + if job.StartAt > 0 { + printer.PrintT(fmt.Sprintf(` ID: {{.Id}} + 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) + } +} + +func importJobShowCmdF(c client.Client, command *cobra.Command, args []string) error { + job, _, err := c.GetJob(args[0]) + if err != nil { + return fmt.Errorf("failed to get import job: %w", err) + } + + printJob(job) + + return nil +} + +func jobListCmdF(c client.Client, command *cobra.Command, jobType 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 + } + + for { + jobs, _, err := c.GetJobsByType(jobType, 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 importJobListCmdF(c client.Client, command *cobra.Command, args []string) error { + return jobListCmdF(c, command, model.JobTypeImportProcess) +} + +type Statistics struct { + 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"` +} + +func importValidateCmdF(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 + ) + + err := withClient(func(c client.Client, cmd *cobra.Command, args []string) error { + users, err := getPages(c.GetUsers, 250) + if err != nil { + return 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(etag, page, numPerPage) + }, 250) + 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(team.Id, page, numPerPage, etag) + }, 250) + if err != nil { + return err + } + + privateChannels, err := getPages(func(page, numPerPage int, etag string) ([]*model.Channel, *model.Response, error) { + return c.GetPrivateChannelsForTeam(team.Id, page, numPerPage, etag) + }, 250) + 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 + })(command, args) + if err != nil { + printer.Print("could not initialize client, skipping online checks\n") + } + + 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: "", + 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 + } + + 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 + ) + + templateError := template.Must(template.New("").Parse("{{ .Error }}\n")) + validator.OnError(func(ive *importer.ImportValidationError) error { + printer.PrintPreparedT(templateError, ive) + return nil + }) + + err = validator.Validate() + if err != nil { + return err + } + + stat := Statistics{ + 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", struct { + FileName string `json:"file_name"` + TotalLines uint64 `json:"total_lines"` + Elapsed time.Duration `json:"elapsed_time_ns"` + }{args[0], validator.Lines(), validator.Duration()}) + + 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" + + "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) +} diff --git a/server/cmd/mmctl/commands/import_e2e_test.go b/server/cmd/mmctl/commands/import_e2e_test.go new file mode 100644 index 0000000000..bb51bd48a2 --- /dev/null +++ b/server/cmd/mmctl/commands/import_e2e_test.go @@ -0,0 +1,367 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "os" + "path/filepath" + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestImportUploadCmdF() { + s.SetupTestHelper().InitBasic() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "tests", importName) + + 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(&model.UploadSession{ + Filename: importName, + FileSize: 276051, + 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() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "tests", importName) + + 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() + serverPath := os.Getenv("MM_SERVER_PATH") + importName := "import_test.zip" + importFilePath := filepath.Join(serverPath, "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 i := 0; i < numImports; i++ { + 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.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("") + 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() + + job, appErr := s.th.App.CreateJob(&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(&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.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(&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(&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(&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)) + }) +} diff --git a/server/cmd/mmctl/commands/import_test.go b/server/cmd/mmctl/commands/import_test.go new file mode 100644 index 0000000000..bf005c3684 --- /dev/null +++ b/server/cmd/mmctl/commands/import_test.go @@ -0,0 +1,226 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" +) + +func (s *MmctlUnitTestSuite) TestImportListAvailableCmdF() { + s.Run("no imports", func() { + printer.Clean() + var mockImports []string + + s.client. + EXPECT(). + ListImports(). + 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(). + 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("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("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(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(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(). + GetJobsByType(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(). + GetJobsByType(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() { + printer.Clean() + importFile := "import.zip" + mockJob := &model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{"import_file": importFile}, + } + + s.client. + EXPECT(). + CreateJob(mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + err := importProcessCmdF(s.client, &cobra.Command{}, []string{importFile}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) +} diff --git a/server/cmd/mmctl/commands/importer/utils.go b/server/cmd/mmctl/commands/importer/utils.go new file mode 100644 index 0000000000..39d50d7bc6 --- /dev/null +++ b/server/cmd/mmctl/commands/importer/utils.go @@ -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() +} diff --git a/server/cmd/mmctl/commands/importer/validate.go b/server/cmd/mmctl/commands/importer/validate.go new file mode 100644 index 0000000000..f40fe37d4b --- /dev/null +++ b/server/cmd/mmctl/commands/importer/validate.go @@ -0,0 +1,1050 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package importer + +import ( + "archive/zip" + "bufio" + "bytes" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "image" + _ "image/gif" // image decoder + _ "image/jpeg" // image decoder + _ "image/png" // image decoder + "io" + "mime" + "os" + "path" + "path/filepath" + "sort" + "strings" + "text/template" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/app/imports" + _ "golang.org/x/image/webp" // image decoder + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +const ( + SourceServer = "" + SourceAdhoc = "" +) + +type ChannelTeam struct { + Channel string + Team string +} + +type Validator struct { //nolint:govet + archiveName string + onError func(*ImportValidationError) error + ignoreAttachments bool + createMissingTeams bool + checkServerDuplicates bool + + serverTeams map[string]*model.Team + serverChannels map[ChannelTeam]*model.Channel + serverUsers map[string]*model.User + serverEmails map[string]*model.User + + attachments map[string]*zip.File + attachmentsUsed map[string]uint64 + allFileNames []string + + schemes map[string]ImportFileInfo + teams map[string]ImportFileInfo + channels map[ChannelTeam]ImportFileInfo + users map[string]ImportFileInfo + posts uint64 + directChannels uint64 + directPosts uint64 + emojis map[string]ImportFileInfo + + start time.Time + end time.Time + + lines uint64 +} + +const ( + LineTypeVersion = "version" + LineTypeScheme = "scheme" + LineTypeTeam = "team" + LineTypeChannel = "channel" + LineTypeUser = "user" + LineTypePost = "post" + LineTypeDirectChannel = "direct_channel" + LineTypeDirectPost = "direct_post" + LineTypeEmoji = "emoji" +) + +func NewValidator( + name string, + ignoreAttachments, + createMissingTeams bool, + checkServerDuplicates bool, + serverTeams map[string]*model.Team, + serverChannels map[ChannelTeam]*model.Channel, + serverUsers map[string]*model.User, + serverEmails map[string]*model.User, +) *Validator { + v := &Validator{ + archiveName: name, + onError: func(ivErr *ImportValidationError) error { return ivErr }, + ignoreAttachments: ignoreAttachments, + createMissingTeams: createMissingTeams, + checkServerDuplicates: checkServerDuplicates, + + serverTeams: serverTeams, + serverChannels: serverChannels, + serverUsers: serverUsers, + serverEmails: serverEmails, + + attachments: make(map[string]*zip.File), + attachmentsUsed: make(map[string]uint64), + + schemes: map[string]ImportFileInfo{}, + teams: map[string]ImportFileInfo{}, + channels: map[ChannelTeam]ImportFileInfo{}, + users: map[string]ImportFileInfo{}, + emojis: map[string]ImportFileInfo{}, + } + + v.loadFromServer() + return v +} + +func (v *Validator) Schemes() uint64 { + return uint64(len(v.schemes)) +} + +func (v *Validator) CreatedTeams() []string { + createdTeams := make([]string, 0, len(v.teams)) + for name, team := range v.teams { + if team.Source == SourceAdhoc { + createdTeams = append(createdTeams, name) + } + } + return createdTeams +} + +func (v *Validator) TeamCount() uint64 { + return uint64(len(v.teams) - len(v.serverTeams)) +} + +func (v *Validator) ChannelCount() uint64 { + return uint64(len(v.channels) - len(v.serverChannels)) +} + +func (v *Validator) UserCount() uint64 { + return uint64(len(v.users) - len(v.serverUsers)) +} + +func (v *Validator) PostCount() uint64 { + return v.posts +} + +func (v *Validator) DirectChannelCount() uint64 { + return v.directChannels +} + +func (v *Validator) DirectPostCount() uint64 { + return v.directPosts +} + +func (v *Validator) Emojis() uint64 { + return uint64(len(v.emojis)) +} + +func (v *Validator) StartTime() time.Time { + return v.start +} + +func (v *Validator) EndTime() time.Time { + return v.end +} + +func (v *Validator) Duration() time.Duration { + return v.end.Sub(v.start) +} + +func (v *Validator) Lines() uint64 { + return v.lines +} + +func (v *Validator) OnError(f func(*ImportValidationError) error) { + if f == nil { + f = func(ivErr *ImportValidationError) error { return ivErr } + } + + v.onError = f +} + +func (v *Validator) createTeam(name string) { + v.teams[name] = ImportFileInfo{ + Source: SourceAdhoc, + } +} + +func (v *Validator) loadFromServer() { + for name := range v.serverTeams { + v.teams[name] = ImportFileInfo{ + Source: SourceServer, + } + } + for channelTeam := range v.serverChannels { + v.channels[channelTeam] = ImportFileInfo{ + Source: SourceServer, + } + } + for name := range v.serverUsers { + v.users[name] = ImportFileInfo{ + Source: SourceServer, + } + } +} + +func (v *Validator) Validate() error { + v.start = time.Now() + defer func() { + v.end = time.Now() + }() + + f, err := os.Open(v.archiveName) + if err != nil { + return fmt.Errorf("error opening the import file %q: %w", v.archiveName, err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("error reading the metadata the input file: %w", err) + } + + z, err := zip.NewReader(f, stat.Size()) + if err != nil { + return fmt.Errorf("error reading the ZIP file: %w", err) + } + + var jsonlZip *zip.File + for _, zfile := range z.File { + if filepath.Ext(zfile.Name) != ".jsonl" { + continue + } + + jsonlZip = zfile + break + } + if jsonlZip == nil { + return fmt.Errorf("could not find a .jsonl file in the import archive") + } + + if !v.ignoreAttachments { + for _, zfile := range z.File { + if zfile.FileInfo().IsDir() { + continue + } + if strings.HasPrefix(zfile.Name, "data/") { + v.attachments[zfile.Name] = zfile + } + v.allFileNames = append(v.allFileNames, zfile.Name) + } + } + + v.lines, err = v.countLines(jsonlZip) + if err != nil { + return err + } + printer.PrintT("The .jsonl file has {{ .Total }} lines\n", struct { + Total uint64 `json:"total_lines"` + }{v.lines}) + + info := ImportFileInfo{ + Source: filepath.Base(v.archiveName), + FileName: jsonlZip.Name, + TotalLines: v.lines, + } + + err = v.validateLines(info, jsonlZip) + if err != nil { + return err + } + + return err +} + +func (v *Validator) countLines(zf *zip.File) (uint64, error) { + f, err := zf.Open() + if err != nil { + return 0, fmt.Errorf("error counting the lines: %w", err) + } + defer f.Close() + + buffer := make([]byte, 64*1024) + count := uint64(0) + + for { + n, err := f.Read(buffer) + + for _, c := range buffer[:n] { + if c == '\n' { + count++ + } + } + + printCount(count) + + if err != nil { + if err == io.EOF { + err = nil + } + return count, err + } + } +} + +func (v *Validator) validateLines(info ImportFileInfo, zf *zip.File) error { + f, err := zf.Open() + if err != nil { + return fmt.Errorf("error validating the lines: %w", err) + } + defer f.Close() + + s := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + s.Buffer(buf, 16*1024*1024) + + for s.Scan() { + info.CurrentLine++ + + rawLine := s.Bytes() + + // filter empty lines + rawLine = bytes.TrimSpace(rawLine) + if len(rawLine) == 0 { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: errors.New("unexpected empty line"), + }); err != nil { + return err + } + } + + // decode the line + var line imports.LineImportData + err = json.Unmarshal(rawLine, &line) + if err != nil { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: err, + }); err != nil { + return err + } + } + + err = v.validateLine(info, line) + if err != nil { + return err + } + + if info.CurrentLine%1024 == 0 { + printProgress(info.CurrentLine, info.TotalLines) + } + } + if err = s.Err(); err != nil { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: err, + }); err != nil { + return err + } + } + + printProgress(info.TotalLines, info.TotalLines) + + return nil +} + +func (v *Validator) validateLine(info ImportFileInfo, line imports.LineImportData) error { + var err error + + // make sure the file starts with a version + if info.CurrentLine == 1 && line.Type != "version" { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: fmt.Errorf("first line has the wrong type: expected \"version\", got %q", line.Type), + }); err != nil { + return err + } + } + + switch line.Type { + case LineTypeVersion: + err = v.validateVersion(info, line) + case LineTypeScheme: + err = v.validateScheme(info, line) + case LineTypeTeam: + err = v.validateTeam(info, line) + case LineTypeChannel: + err = v.validateChannel(info, line) + case LineTypeUser: + err = v.validateUser(info, line) + case LineTypePost: + err = v.validatePost(info, line) + case LineTypeDirectChannel: + err = v.validateDirectChannel(info, line) + case LineTypeDirectPost: + err = v.validateDirectPost(info, line) + case LineTypeEmoji: + err = v.validateEmoji(info, line) + default: + err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + FieldName: "type", + Err: fmt.Errorf("unknown import type %q", line.Type), + }) + } + + return err +} + +func (v *Validator) validateVersion(info ImportFileInfo, line imports.LineImportData) (err error) { + if info.CurrentLine != 1 { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: fmt.Errorf("version info must be the first line of the file"), + }); err != nil { + return err + } + } + + if line.Version == nil { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: fmt.Errorf("version must not be null or missing"), + }); err != nil { + return err + } + } else if *line.Version != 1 { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + Err: fmt.Errorf("version must not be 1"), + }); err != nil { + return err + } + } + + return nil +} + +func (v *Validator) validateScheme(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "scheme", line.Scheme, func(data imports.SchemeImportData) *ImportValidationError { + appErr := imports.ValidateSchemeImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "scheme", + Err: appErr, + } + } + + if data.Name != nil { + if existing, ok := v.schemes[*data.Name]; ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "scheme", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + v.schemes[*data.Name] = info + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + +func (v *Validator) checkDuplicateTeam(info ImportFileInfo, team string) *ImportValidationError { + if v.checkServerDuplicates { + if existing, ok := v.serverTeams[team]; ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "team", + Err: fmt.Errorf("duplicate entry, server already has a team named %q (display: %q, id: %s)", existing.Name, existing.DisplayName, existing.Id), + } + } + } + + if existing, ok := v.teams[team]; ok && existing.Source != SourceServer { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "team", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + + return nil +} + +func (v *Validator) validateTeam(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "team", line.Team, func(data imports.TeamImportData) *ImportValidationError { + appErr := imports.ValidateTeamImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "team", + Err: appErr, + } + } + + if data.Name != nil { + if ive := v.checkDuplicateTeam(info, *data.Name); ive != nil { + return ive + } + v.teams[*data.Name] = info + } + if data.Scheme != nil { + if _, ok := v.schemes[*data.Scheme]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "team.scheme", + Err: fmt.Errorf("reference to unknown scheme %q", *data.Scheme), + } + } + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + +func (v *Validator) checkDuplicateChannel(info ImportFileInfo, team, channel string) *ImportValidationError { + if v.checkServerDuplicates { + if existing, ok := v.serverChannels[ChannelTeam{Channel: channel, Team: team}]; ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "channel", + Err: fmt.Errorf("duplicate entry, server already has a channel %q (display: %q, id: %s) in team %q", existing.Name, existing.DisplayName, existing.Id, team), + } + } + } + + if existing, ok := v.channels[ChannelTeam{Channel: channel, Team: team}]; ok && existing.Source != SourceServer { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "channel", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + + return nil +} + +func (v *Validator) validateChannel(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "channel", line.Channel, func(data imports.ChannelImportData) *ImportValidationError { + appErr := imports.ValidateChannelImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "channel", + Err: appErr, + } + } + + if data.Team != nil { + if _, ok := v.teams[*data.Team]; !ok { + if v.createMissingTeams { + v.createTeam(*data.Team) + } else { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "channel.team", + Err: fmt.Errorf("reference to unknown team %q", *data.Team), + } + } + } + } + if data.Name != nil { + if ive := v.checkDuplicateChannel(info, *data.Team, *data.Name); ive != nil { + return ive + } + v.channels[ChannelTeam{Channel: *data.Name, Team: *data.Team}] = info + } + if data.Scheme != nil { + if _, ok := v.schemes[*data.Scheme]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "channel.scheme", + Err: fmt.Errorf("reference to unknown scheme %q", *data.Scheme), + } + } + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + +func (v *Validator) checkDuplicateUser(info ImportFileInfo, username, email string) *ImportValidationError { + if existing, ok := v.serverUsers[username]; ok { + if emailUser, ok := v.serverEmails[email]; ok { + if existing.Id != emailUser.Id { + // there is another user which already has this email address, this will result in + // an import errors regardless of the user merging. + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "user", + Err: fmt.Errorf("email address %q for %q is already used by another user %q (id: %s)", email, username, emailUser.Username, emailUser.Id), + } + } + } + + if v.checkServerDuplicates { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "user", + Err: fmt.Errorf("duplicate entry, server already has a user %q (email: %q, id: %s)", existing.Username, existing.Email, existing.Id), + } + } + } + + if existing, ok := v.users[username]; ok && existing.Source != SourceServer { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "user", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + + return nil +} + +func (v *Validator) validateUser(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "user", line.User, func(data imports.UserImportData) *ImportValidationError { + appErr := imports.ValidateUserImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "user", + Err: appErr, + } + } + + if data.Username != nil { + if ive := v.checkDuplicateUser(info, *data.Username, *data.Email); ive != nil { + return ive + } + v.users[*data.Username] = info + } + if data.Teams != nil { + for i, team := range *data.Teams { + if _, ok := v.teams[*team.Name]; !ok { + if v.createMissingTeams { + v.createTeam(*team.Name) + } else { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("user.teams[%d]", i), + Err: fmt.Errorf("reference to unknown team %q", *team.Name), + } + } + } + } + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + +func (v *Validator) validatePost(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "post", line.Post, func(data imports.PostImportData) *ImportValidationError { + appErr := imports.ValidatePostImportData(&data, model.PostMessageMaxRunesV1) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "post", + Err: appErr, + } + } + + if data.Team != nil { + if _, ok := v.teams[*data.Team]; !ok { + if v.createMissingTeams { + v.createTeam(*data.Team) + } else { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "post.team", + Err: fmt.Errorf("reference to unknown team %q", *data.Team), + } + } + } + } + if data.Channel != nil { + if _, ok := v.channels[ChannelTeam{Channel: *data.Channel, Team: *data.Team}]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "post.channel", + Err: fmt.Errorf("reference to unknown channel \"%s/%s\"", *data.Team, *data.Channel), + } + } + } + if data.User != nil { + if _, ok := v.users[*data.User]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "post.user", + Err: fmt.Errorf("reference to unknown user %q", *data.User), + } + } + } + + return nil + }) + if ivErr != nil { + if err = v.onError(ivErr); err != nil { + return err + } + } + + if !v.ignoreAttachments && line.Post != nil && line.Post.Attachments != nil { + for i, attachment := range *line.Post.Attachments { + if attachment.Path == nil { + continue + } + + attachmentPath := path.Join("data", *attachment.Path) + + if _, ok := v.attachments[attachmentPath]; !ok { + helpful := "" + candidates := v.findFileNameSuffix(*attachment.Path) + if len(candidates) != 0 { + helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\"" + } + + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("post.attachments[%d]", i), + Err: fmt.Errorf("missing attachment file %q%s", attachmentPath, helpful), + }); err != nil { + return err + } + } else { + v.attachmentsUsed[attachmentPath]++ + } + } + } + + v.posts++ + + return nil +} + +func (v *Validator) validateDirectChannel(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "direct_channel", line.DirectChannel, func(data imports.DirectChannelImportData) *ImportValidationError { + appErr := imports.ValidateDirectChannelImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "direct_channel", + Err: appErr, + } + } + + if data.FavoritedBy != nil { + for i, favoritedBy := range *data.FavoritedBy { + if _, ok := v.users[favoritedBy]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("direct_channel.favorited_by[%d]", i), + Err: fmt.Errorf("reference to unknown user %q", favoritedBy), + } + } + } + } + + if data.Members != nil { + for i, member := range *data.Members { + if _, ok := v.users[member]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("direct_channel.members[%d]", i), + Err: fmt.Errorf("reference to unknown user %q", member), + } + } + } + } + + return nil + }) + if ivErr != nil { + if err = v.onError(ivErr); err != nil { + return err + } + } + + v.directChannels++ + + return nil +} + +func (v *Validator) validateDirectPost(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "direct_post", line.DirectPost, func(data imports.DirectPostImportData) *ImportValidationError { + appErr := imports.ValidateDirectPostImportData(&data, model.PostMessageMaxRunesV1) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "post", + Err: appErr, + } + } + + if data.User != nil { + if _, ok := v.users[*data.User]; !ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "direct_post.user", + Err: fmt.Errorf("reference to unknown user %q", *data.User), + } + } + } + + return nil + }) + if ivErr != nil { + if err = v.onError(ivErr); err != nil { + return err + } + } + + if line.DirectPost != nil && line.DirectPost.ChannelMembers != nil { + for i, member := range *line.DirectPost.ChannelMembers { + if _, ok := v.users[member]; !ok { + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("direct_post.channel_members[%d]", i), + Err: fmt.Errorf("reference to unknown user %q", member), + }); err != nil { + return err + } + } + } + } + + if !v.ignoreAttachments && line.DirectPost != nil && line.DirectPost.Attachments != nil { + for i, attachment := range *line.DirectPost.Attachments { + if attachment.Path == nil { + continue + } + + attachmentPath := path.Join("data", *attachment.Path) + + if _, ok := v.attachments[attachmentPath]; !ok { + helpful := "" + candidates := v.findFileNameSuffix(*attachment.Path) + if len(candidates) != 0 { + helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\"" + } + + if err = v.onError(&ImportValidationError{ + ImportFileInfo: info, + FieldName: fmt.Sprintf("direct_post.attachments[%d]", i), + Err: fmt.Errorf("missing attachment file %q%s", attachmentPath, helpful), + }); err != nil { + return err + } + } else { + v.attachmentsUsed[attachmentPath]++ + } + } + } + + v.directPosts++ + + return nil +} + +func (v *Validator) validateEmoji(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "emoji", line.Emoji, func(data imports.EmojiImportData) *ImportValidationError { + appErr := imports.ValidateEmojiImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji", + Err: appErr, + } + } + + if data.Name != nil { + if existing, ok := v.emojis[*data.Name]; ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + v.emojis[*data.Name] = info + } + + if !v.ignoreAttachments && data.Image != nil { + attachmentPath := path.Join("data", *data.Image) + + zfile, ok := v.attachments[attachmentPath] + if !ok { + helpful := "" + candidates := v.findFileNameSuffix(*data.Image) + if len(candidates) != 0 { + helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\"" + } + + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji.image", + Err: fmt.Errorf("missing image file for emoji %s: %q%s", *data.Name, attachmentPath, helpful), + } + } + + return v.validateSupportedImage(info, zfile) + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + +func (v *Validator) Attachments() []string { + used := make([]string, 0, len(v.attachmentsUsed)) + for attachment := range v.attachmentsUsed { + used = append(used, attachment) + } + sort.Strings(used) + return used +} + +func (v *Validator) UnusedAttachments() []string { + var unused []string + for attachment := range v.attachments { + if _, ok := v.attachmentsUsed[attachment]; !ok { + unused = append(unused, attachment) + } + } + sort.Strings(unused) + return unused +} + +func (v *Validator) validateSupportedImage(info ImportFileInfo, zfile *zip.File) *ImportValidationError { + f, err := zfile.Open() + if err != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji.image", + Err: fmt.Errorf("error opening emoji image: %w", err), + } + } + defer f.Close() + + if mime.TypeByExtension(strings.ToLower(path.Ext(zfile.Name))) == "image/svg+xml" { + var svg struct{} + err = xml.NewDecoder(f).Decode(&svg) + if err != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji.image", + Err: fmt.Errorf("error decoding emoji SVG file: %w", err), + } + } + + return nil + } + + _, _, err = image.Decode(f) + if err != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "emoji.image", + Err: fmt.Errorf("error decoding emoji image: %w", err), + } + } + + return nil +} + +func validateNotNil[T any](info ImportFileInfo, name string, value *T, then func(T) *ImportValidationError) *ImportValidationError { + if value == nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: name, + Err: errors.New("field must not be null or missing"), + } + } + + if then != nil { + return then(*value) + } + + return nil +} + +func (v *Validator) findFileNameSuffix(name string) []string { + var candidates []string + for _, fileName := range v.allFileNames { + if strings.HasSuffix(fileName, name) { + candidates = append(candidates, fileName) + } + } + return candidates +} + +var progressTemplate = template.Must(template.New("").Parse("Progress: {{ .Current }}/{{ .Total }} ({{ printf \"%.2f\" .Percent }}%)\r")) + +func printProgress(current, total uint64) { + percent := float64(current) * 100 / float64(total) + + data := struct { + Current uint64 `json:"current_line"` + Total uint64 `json:"total_lines"` + Percent float64 `json:"percent"` + }{current, total, percent} + + printer.PrintPreparedT(progressTemplate, data) + printer.Flush() +} + +var countTemplate = template.Must(template.New("").Parse("Counting lines: {{ .Total }}\r")) + +func printCount(total uint64) { + data := struct { + Total uint64 `json:"total_lines"` + }{total} + + printer.PrintPreparedT(countTemplate, data) + printer.Flush() +} diff --git a/server/cmd/mmctl/commands/init.go b/server/cmd/mmctl/commands/init.go new file mode 100644 index 0000000000..fe65e08295 --- /dev/null +++ b/server/cmd/mmctl/commands/init.go @@ -0,0 +1,233 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + "runtime" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +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 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 { + if viper.GetBool("local") { + c, err := InitUnixClient(viper.GetString("local-socket-path")) + if err != nil { + return err + } + printer.SetServerAddres("local instance") + return fn(c, cmd, args) + } + + c, serverVersion, err := InitClient(viper.GetBool("insecure-sha1-intermediate"), viper.GetBool("insecure-tls-version")) + if err != nil { + return err + } + + 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 + for _, chain := range verifiedChains { + if isValidChain(chain) { + 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(username, password, instanceURL string, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) { + client := NewAPIv4Client(instanceURL, allowInsecureSHA1, allowInsecureTLS) + + _, resp, err := client.Login(username, password) + if err != nil { + return nil, "", checkInsecureTLSError(err, allowInsecureTLS) + } + return client, resp.ServerVersion, nil +} + +func InitClientWithMFA(username, password, mfaToken, instanceURL string, allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) { + client := NewAPIv4Client(instanceURL, allowInsecureSHA1, allowInsecureTLS) + _, resp, err := client.LoginWithMFA(username, password, mfaToken) + if err != nil { + return nil, "", checkInsecureTLSError(err, allowInsecureTLS) + } + return client, resp.ServerVersion, nil +} + +func InitClientWithCredentials(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("") + if err != nil { + return nil, "", checkInsecureTLSError(err, allowInsecureTLS) + } + + return client, resp.ServerVersion, nil +} + +func InitClient(allowInsecureSHA1, allowInsecureTLS bool) (*model.Client4, string, error) { + credentials, err := GetCurrentCredentials() + if err != nil { + return nil, "", err + } + return InitClientWithCredentials(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 +} diff --git a/server/cmd/mmctl/commands/init_test.go b/server/cmd/mmctl/commands/init_test.go new file mode 100644 index 0000000000..c27567dee7 --- /dev/null +++ b/server/cmd/mmctl/commands/init_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "crypto/x509" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/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) + + err = os.Setenv("HTTP_PROXY", proxyAddr) + require.NoError(t, err) + defer os.Unsetenv("HTTP_PROXY") + + client := NewAPIv4Client("http://somethingelse:"+port, false, false) + _, _, err = client.GetMe("") + require.NoError(t, err) + }) +} diff --git a/server/cmd/mmctl/commands/integrity.go b/server/cmd/mmctl/commands/integrity.go new file mode 100644 index 0000000000..da9a09dd0c --- /dev/null +++ b/server/cmd/mmctl/commands/integrity.go @@ -0,0 +1,103 @@ +// 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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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() + 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() +} diff --git a/server/cmd/mmctl/commands/integrity_test.go b/server/cmd/mmctl/commands/integrity_test.go new file mode 100644 index 0000000000..fb863dbd65 --- /dev/null +++ b/server/cmd/mmctl/commands/integrity_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + + "github.com/hashicorp/go-multierror" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/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: model.NewString("parentId"), + ChildId: model.NewString("childId"), + }, + }, + } + mockResults := []model.IntegrityCheckResult{ + { + Data: mockData, + Err: nil, + }, + } + s.client. + EXPECT(). + CheckIntegrity(). + 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(). + 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: model.NewString("parentId"), + ChildId: model.NewString("childId"), + }, + }, + } + mockResults := []model.IntegrityCheckResult{ + { + Data: nil, + Err: errors.New("test error"), + }, + { + Data: mockData, + Err: nil, + }, + } + s.client. + EXPECT(). + CheckIntegrity(). + 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]) + }) +} diff --git a/server/cmd/mmctl/commands/ldap.go b/server/cmd/mmctl/commands/ldap.go new file mode 100644 index 0000000000..9f35d814f8 --- /dev/null +++ b/server/cmd/mmctl/commands/ldap.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +var LdapCmd = &cobra.Command{ + Use: "ldap", + Short: "LDAP related utilities", +} + +var LdapSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Synchronize now", + Long: "Synchronize all LDAP users and groups now.", + Example: " ldap sync", + RunE: withClient(ldapSyncCmdF), +} + +var LdapIDMigrate = &cobra.Command{ + Use: "idmigrate ", + 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: " ldap idmigrate objectGUID", + Args: cobra.ExactArgs(1), + RunE: withClient(ldapIDMigrateCmdF), +} + +func init() { + LdapSyncCmd.Flags().Bool("include-removed-members", false, "Include members who left or were removed from a group-synced team/channel") + LdapCmd.AddCommand( + LdapSyncCmd, + LdapIDMigrate, + ) + RootCmd.AddCommand(LdapCmd) +} + +func ldapSyncCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + includeRemovedMembers, _ := cmd.Flags().GetBool("include-removed-members") + + resp, err := c.SyncLdap(includeRemovedMembers) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusOK { + printer.PrintT("Status: {{.status}}", map[string]interface{}{"status": "ok"}) + } else { + printer.PrintT("Status: {{.status}}", map[string]interface{}{"status": "error"}) + } + + return nil +} + +func ldapIDMigrateCmdF(c client.Client, cmd *cobra.Command, args []string) error { + toAttribute := args[0] + resp, err := c.MigrateIdLdap(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 +} diff --git a/server/cmd/mmctl/commands/ldap_e2e_test.go b/server/cmd/mmctl/commands/ldap_e2e_test.go new file mode 100644 index 0000000000..2665dde4fd --- /dev/null +++ b/server/cmd/mmctl/commands/ldap_e2e_test.go @@ -0,0 +1,129 @@ +// 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/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/mattermost/mattermost-server/server/v8/channels/utils/testutils" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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" + *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() + configForLdap(s.th) + + s.Run("MM-T3971 Should not allow regular user to sync LDAP groups", 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 sync LDAP groups", func(c client.Client) { + printer.Clean() + + jobs, appErr := s.th.App.GetJobsByTypePage(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]interface{}{"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(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() + 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("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(ldapUser.Id) + s.Require().Nil(appErr) + s.Require().Equal("Dev1", *updatedUser.AuthData) + }) +} diff --git a/server/cmd/mmctl/commands/ldap_test.go b/server/cmd/mmctl/commands/ldap_test.go new file mode 100644 index 0000000000..6c90fd26b5 --- /dev/null +++ b/server/cmd/mmctl/commands/ldap_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestLdapSyncCmd() { + s.Run("Sync without errors", func() { + printer.Clean() + outputMessage := map[string]interface{}{"status": "ok"} + + s.client. + EXPECT(). + SyncLdap(false). + 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]interface{}{"status": "error"} + + s.client. + EXPECT(). + SyncLdap(false). + 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(false). + 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) + }) + + s.Run("Sync with includeRemoveMembers", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().Bool("include-removed-members", true, "") + + s.client. + EXPECT(). + SyncLdap(true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := ldapSyncCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + }) +} + +func (s *MmctlUnitTestSuite) TestLdapMigrateID() { + s.Run("Run successfully without errors", func() { + printer.Clean() + + s.client. + EXPECT(). + MigrateIdLdap("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("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) + }) +} diff --git a/server/cmd/mmctl/commands/license.go b/server/cmd/mmctl/commands/license.go new file mode 100644 index 0000000000..8d9d6c84b5 --- /dev/null +++ b/server/cmd/mmctl/commands/license.go @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "io/ioutil" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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), +} + +func init() { + LicenseCmd.AddCommand(UploadLicenseCmd) + LicenseCmd.AddCommand(RemoveLicenseCmd) + LicenseCmd.AddCommand(UploadLicenseStringCmd) + 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(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 := ioutil.ReadFile(args[0]) + if err != nil { + return err + } + + if _, err := c.UploadLicenseFile(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(); err != nil { + return err + } + + printer.Print("Removed license") + + return nil +} diff --git a/server/cmd/mmctl/commands/license_e2e_test.go b/server/cmd/mmctl/commands/license_e2e_test.go new file mode 100644 index 0000000000..dc9abf968c --- /dev/null +++ b/server/cmd/mmctl/commands/license_e2e_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "io/ioutil" + "os" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestRemoveLicenseCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + 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) + s.Require().Nil(s.th.App.Srv().License()) + }) +} + +func (s *MmctlE2ETestSuite) TestUploadLicenseCmdF() { + s.SetupEnterpriseTestHelper().InitBasic() + + // create temporary file + tmpFile, err := ioutil.TempFile(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) + }) +} diff --git a/server/cmd/mmctl/commands/license_test.go b/server/cmd/mmctl/commands/license_test.go new file mode 100644 index 0000000000..e9cabc0a0a --- /dev/null +++ b/server/cmd/mmctl/commands/license_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "net/http" + "os" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/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(). + 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(). + 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 := ioutil.TempFile(os.TempDir(), "testLicense-") + if err != nil { + panic(err) + } + text := []byte(fakeLicensePayload) + if _, err = tmpFile.Write(text); err != nil { + panic(err) + } + defer os.Remove(tmpFile.Name()) + + mockLicenseFile := []byte(fakeLicensePayload) + + s.Run("Upload license successfully", func() { + printer.Clean() + s.client. + EXPECT(). + UploadLicenseFile(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(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(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") + }) +} diff --git a/server/cmd/mmctl/commands/logs.go b/server/cmd/mmctl/commands/logs.go new file mode 100644 index 0000000000..ce89f08960 --- /dev/null +++ b/server/cmd/mmctl/commands/logs.go @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + "github.com/mattermost/mattermost-server/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", 200, "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(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 +} diff --git a/server/cmd/mmctl/commands/logs_e2e_test.go b/server/cmd/mmctl/commands/logs_e2e_test.go new file mode 100644 index 0000000000..8560e4a9de --- /dev/null +++ b/server/cmd/mmctl/commands/logs_e2e_test.go @@ -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/server/v8/cmd/mmctl/client" + + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestlogsCmdF() { + s.SetupTestHelper().InitBasic() + + 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) + }) +} diff --git a/server/cmd/mmctl/commands/logs_test.go b/server/cmd/mmctl/commands/logs_test.go new file mode 100644 index 0000000000..5eeec30497 --- /dev/null +++ b/server/cmd/mmctl/commands/logs_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/mattermost/mattermost-server/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(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(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(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 +} diff --git a/server/cmd/mmctl/commands/main_test.go b/server/cmd/mmctl/commands/main_test.go new file mode 100644 index 0000000000..b0181b3af4 --- /dev/null +++ b/server/cmd/mmctl/commands/main_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build e2e +// +build e2e + +package commands + +import ( + "testing" + + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/mattermost/mattermost-server/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) +} diff --git a/server/cmd/mmctl/commands/mmctl_e2e_test.go b/server/cmd/mmctl/commands/mmctl_e2e_test.go new file mode 100644 index 0000000000..affb5d6bf0 --- /dev/null +++ b/server/cmd/mmctl/commands/mmctl_e2e_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build e2e +// +build e2e + +package commands + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +func TestMmctlE2ESuite(t *testing.T) { + suite.Run(t, new(MmctlE2ETestSuite)) +} diff --git a/server/cmd/mmctl/commands/mmctl_test.go b/server/cmd/mmctl/commands/mmctl_test.go new file mode 100644 index 0000000000..b5d0a84cad --- /dev/null +++ b/server/cmd/mmctl/commands/mmctl_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/mocks" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/mattermost/mattermost-server/server/v8/channels/api4" +) + +var EnableEnterpriseTests string + +type MmctlUnitTestSuite struct { + suite.Suite + mockCtrl *gomock.Controller + client *mocks.MockClient +} + +func (s *MmctlUnitTestSuite) SetupTest() { + 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() +} + +type MmctlE2ETestSuite struct { + suite.Suite + th *api4.TestHelper +} + +func (s *MmctlE2ETestSuite) SetupTest() { + printer.Clean() + printer.SetFormat(printer.FormatJSON) +} + +func (s *MmctlE2ETestSuite) TearDownTest() { + // if a test helper was used, we run the teardown and remove it + // from the structure to avoid reusing the same helper between + // tests + if s.th != nil { + s.th.TearDown() + s.th = nil + } +} + +func (s *MmctlE2ETestSuite) SetupTestHelper() *api4.TestHelper { + s.th = api4.Setup(s.T()) + return s.th +} + +func (s *MmctlE2ETestSuite) SetupEnterpriseTestHelper() *api4.TestHelper { + if EnableEnterpriseTests != "true" { + s.T().SkipNow() + } + s.th = api4.SetupEnterprise(s.T()) + 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) + }) +} diff --git a/server/cmd/mmctl/commands/mmctl_unit_test.go b/server/cmd/mmctl/commands/mmctl_unit_test.go new file mode 100644 index 0000000000..949934d4b7 --- /dev/null +++ b/server/cmd/mmctl/commands/mmctl_unit_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build unit +// +build unit + +package commands + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +func TestMmctlUnitSuite(t *testing.T) { + suite.Run(t, new(MmctlUnitTestSuite)) +} diff --git a/server/cmd/mmctl/commands/permission_role_test.go b/server/cmd/mmctl/commands/permission_role_test.go new file mode 100644 index 0000000000..82ec0ce75d --- /dev/null +++ b/server/cmd/mmctl/commands/permission_role_test.go @@ -0,0 +1,467 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Username, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + + for _, user := range []*model.User{mockUser1, mockUser2} { + s.client. + EXPECT(). + GetUserByEmail(user.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(user.Username, ""). + Return(user, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(user.Id, fmt.Sprintf("%s %s", user.Roles, mockRole.Name)). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + } + + s.client. + EXPECT(). + GetUserByEmail(notFoundUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(notFoundUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(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("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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(requestedUser, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(requestedUser, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(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(). + GetUserByEmail(mockUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Username, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(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(). + GetUserByEmail(user.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(user.Username, ""). + Return(user, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(user.Id, strings.TrimSpace(strings.ReplaceAll(user.Roles, roleName, ""))). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + } + + s.client. + EXPECT(). + GetUserByEmail(notFoundUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(notFoundUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(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(). + GetUserByEmail(mockUser.Username, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(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(). + GetUserByEmail(requestedUser, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(requestedUser, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(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(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(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(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) + }) +} diff --git a/server/cmd/mmctl/commands/permissions.go b/server/cmd/mmctl/commands/permissions.go new file mode 100644 index 0000000000..4e83339890 --- /dev/null +++ b/server/cmd/mmctl/commands/permissions.go @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 ", + 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 ", + 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 ShowRoleCmd = &cobra.Command{ + Use: "show ", + Deprecated: "please use \"role show\" instead", + Short: "Show the role information", + Long: "Show all the information about a role.", + Example: ` permissions show system_user`, + Args: cobra.ExactArgs(1), + RunE: withClient(showRoleCmdF), +} + +var ResetCmd = &cobra.Command{ + Use: "reset ", + 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, + ShowRoleCmd, + ResetCmd, + ) + + RootCmd.AddCommand(PermissionsCmd) +} + +func addPermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + role, _, err := c.GetRoleByName(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(role.Id, &patchRole); err != nil { + return err + } + + return nil +} + +func removePermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + role, _, err := c.GetRoleByName(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(role.Id, &patchRole); err != nil { + return err + } + + return nil +} + +func resetPermissionsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + role, _, err := c.GetRoleByName(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(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 +} diff --git a/server/cmd/mmctl/commands/permissions_e2e_test.go b/server/cmd/mmctl/commands/permissions_e2e_test.go new file mode 100644 index 0000000000..57abcedd0d --- /dev/null +++ b/server/cmd/mmctl/commands/permissions_e2e_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "context" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestShowRoleCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + 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() + + role, appErr := s.th.App.GetRoleByName(context.Background(), 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(context.Background(), model.SystemUserRoleId) + s.Require().Nil(appErr) + s.Require().Contains(updatedRole.Permissions, model.PermissionCreateBot.Id) + }) +} + +func (s *MmctlE2ETestSuite) TestRemovePermissionsCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + role, appErr := s.th.App.GetRoleByName(context.Background(), 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(context.Background(), model.SystemUserRoleId) + s.Require().Nil(appErr) + s.Require().NotContains(updatedRole.Permissions, model.PermissionCreateDirectChannel.Id) + }) +} diff --git a/server/cmd/mmctl/commands/permissions_reset_e2e_test.go b/server/cmd/mmctl/commands/permissions_reset_e2e_test.go new file mode 100644 index 0000000000..580b4aad43 --- /dev/null +++ b/server/cmd/mmctl/commands/permissions_reset_e2e_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" +) + +func (s *MmctlE2ETestSuite) TestResetPermissionsCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), model.SystemUserManagerRoleId) + s.Require().Nil(err) + s.Require().ElementsMatch(defaultPermissions, roleAfterResetAttempt.Permissions) + }) +} diff --git a/server/cmd/mmctl/commands/permissions_role.go b/server/cmd/mmctl/commands/permissions_role.go new file mode 100644 index 0000000000..a63bde9372 --- /dev/null +++ b/server/cmd/mmctl/commands/permissions_role.go @@ -0,0 +1,226 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 ", + Short: "Show the role information", + Long: "Show all the information about a role.", + Example: ` permissions show system_user`, + Args: cobra.ExactArgs(1), + RunE: withClient(showRoleCmdF), +} + +var AssignCmd = &cobra.Command{ + Use: "assign ", + 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 ", + 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 { + sort.Strings(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(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(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(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(user.Id, strings.Join(userRoles, " ")) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/server/cmd/mmctl/commands/permissions_role_e2e_test.go b/server/cmd/mmctl/commands/permissions_role_e2e_test.go new file mode 100644 index 0000000000..ced8919eea --- /dev/null +++ b/server/cmd/mmctl/commands/permissions_role_e2e_test.go @@ -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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestAssignUsersCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), 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(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() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), 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(user.Id) + s.Require().Nil(err2) + s.Require().False(u.IsInRole(model.SystemManagerRoleId)) + }) +} diff --git a/server/cmd/mmctl/commands/permissions_test.go b/server/cmd/mmctl/commands/permissions_test.go new file mode 100644 index 0000000000..c3117f66ac --- /dev/null +++ b/server/cmd/mmctl/commands/permissions_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + gomock "github.com/golang/mock/gomock" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchRole(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(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(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(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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchRole(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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchRole(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(mockRole.Name). + Return(mockRole, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PatchRole(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(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(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(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"} + expectedPatch := &model.RolePatch{ + Permissions: &expectedPermissions, + } + + s.client. + EXPECT(). + GetRoleByName(mockRole.Name). + Return(&mockRole, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PatchRole(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) + }) +} diff --git a/server/cmd/mmctl/commands/plugin.go b/server/cmd/mmctl/commands/plugin.go new file mode 100644 index 0000000000..bc187c34d8 --- /dev/null +++ b/server/cmd/mmctl/commands/plugin.go @@ -0,0 +1,196 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "os" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 ...", + 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") + + for i, plugin := range args { + fileReader, err := os.Open(plugin) + if err != nil { + return err + } + + if force { + _, _, err = c.UploadPluginForced(fileReader) + } else { + _, _, err = c.UploadPlugin(fileReader) + } + + if err != nil { + printer.PrintError("Unable to add plugin: " + args[i] + ". Error: " + err.Error()) + } else { + printer.Print("Added plugin: " + plugin) + } + fileReader.Close() + } + + return nil +} + +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(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 { + for _, plugin := range args { + if _, err := c.RemovePlugin(plugin); err != nil { + printer.PrintError("Unable to delete plugin: " + plugin + ". Error: " + err.Error()) + } else { + printer.Print("Deleted plugin: " + plugin) + } + } + + return nil +} + +func pluginEnableCmdF(c client.Client, cmd *cobra.Command, args []string) error { + for _, plugin := range args { + if _, err := c.EnablePlugin(plugin); err != nil { + printer.PrintError("Unable to enable plugin: " + plugin + ". Error: " + err.Error()) + } else { + printer.Print("Enabled plugin: " + plugin) + } + } + + return nil +} + +func pluginDisableCmdF(c client.Client, cmd *cobra.Command, args []string) error { + for _, plugin := range args { + if _, err := c.DisablePlugin(plugin); err != nil { + printer.PrintError("Unable to disable plugin: " + plugin + ". Error: " + err.Error()) + } else { + printer.Print("Disabled plugin: " + plugin) + } + } + + return nil +} + +func pluginListCmdF(c client.Client, cmd *cobra.Command, args []string) error { + pluginsResp, _, err := c.GetPlugins() + 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("Listing disabled plugins") + for _, plugin := range pluginsResp.Inactive { + printer.PrintT("{{.Manifest.Id}}: {{.Manifest.Name}}, Version: {{.Manifest.Version}}", plugin) + } + } + + return nil +} diff --git a/server/cmd/mmctl/commands/plugin_e2e_test.go b/server/cmd/mmctl/commands/plugin_e2e_test.go new file mode 100644 index 0000000000..118df45530 --- /dev/null +++ b/server/cmd/mmctl/commands/plugin_e2e_test.go @@ -0,0 +1,381 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "os" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestPluginAddCmd() { + s.SetupTestHelper().InitBasic() + + pluginPath := filepath.Join(os.Getenv("MM_SERVER_PATH"), "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().Nil(err) + + 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().Nil(err) + 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().Nil(err) + 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.th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.EnableUploads = true + }) + + const ( + jiraURL = "https://plugins-store.test.mattermost.com/release/mattermost-plugin-jira-v3.0.0.tar.gz" + jiraPluginID = "jira" + githubURL = "https://plugins-store.test.mattermost.com/release/mattermost-plugin-github-v2.0.0.tar.gz" + githubPluginID = "github" + ) + + s.RunForSystemAdminAndLocal("install new plugins", func(c client.Client) { + printer.Clean() + defer removePluginIfInstalled(c, s, jiraPluginID) + defer removePluginIfInstalled(c, s, githubPluginID) + + err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{jiraURL, githubURL}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, printer.GetLines()[0].(*model.Manifest).Id) + s.Require().Equal(githubPluginID, 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, jiraPluginID) + + 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{jiraURL}) + s.Require().EqualError(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\".", jiraURL)) + 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() + + const pluginURL = "https://plugins-store.test.mattermost.com/release/mattermost-nonexistent-plugin-v2.0.0.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{pluginURL}) + s.Require().EqualError(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\".", pluginURL)) + 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, jiraPluginID) + + err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{jiraURL}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, printer.GetLines()[0].(*model.Manifest).Id) + + var expected error + expected = multierror.Append(expected, errors.New(": Unable to install plugin. A plugin with the same ID is already installed.")) //nolint:revive + err = pluginInstallURLCmdF(c, &cobra.Command{}, []string{jiraURL}) + s.Require().EqualError(err, expected.Error()) + 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\".", jiraURL)) + 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, jiraPluginID) + + err := pluginInstallURLCmdF(c, &cobra.Command{}, []string{jiraURL}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, printer.GetLines()[0].(*model.Manifest).Id) + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", true, "") + err = pluginInstallURLCmdF(c, cmd, []string{jiraURL}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, 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() + + const ( + jiraURL = "https://plugins-store.test.mattermost.com/release/mattermost-plugin-jira-v3.0.0.tar.gz" + jiraPluginID = "jira" + dummyPluginID = "randompluginxz" // This will be used to check response when tried to delete this plugin with randomchars which was not installed/enabled already + ) + + 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 + }) + + defer s.th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + *cfg.PluginSettings.EnableUploads = false + }) + + errInstall := pluginInstallURLCmdF(c, &cobra.Command{}, []string{jiraURL}) + s.Require().Nil(errInstall) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, 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{jiraPluginID}) + 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().Nil(err) + 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 + }) + + defer func() { + errDelete := pluginDeleteCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{jiraPluginID}) + s.Require().Nil(errDelete) + s.th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + *cfg.PluginSettings.EnableUploads = false + }) + }() + + // Installs plugin using SystemAdmin Privilege and check whether plugin has been installed properly so that delete plugin test can be done + errInstall := pluginInstallURLCmdF(s.th.SystemAdminClient, &cobra.Command{}, []string{jiraURL}) + s.Require().Nil(errInstall) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(jiraPluginID, 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) + + // Delete Test + err := pluginDeleteCmdF(s.th.Client, &cobra.Command{}, []string{jiraPluginID}) + s.Require().Nil(err) + 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.", jiraPluginID)) + 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) + }) +} diff --git a/server/cmd/mmctl/commands/plugin_marketplace.go b/server/cmd/mmctl/commands/plugin_marketplace.go new file mode 100644 index 0000000000..8a736072bb --- /dev/null +++ b/server/cmd/mmctl/commands/plugin_marketplace.go @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/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 ", + 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", 200, "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(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(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 +} diff --git a/server/cmd/mmctl/commands/plugin_marketplace_e2e_test.go b/server/cmd/mmctl/commands/plugin_marketplace_e2e_test.go new file mode 100644 index 0000000000..8059781a79 --- /dev/null +++ b/server/cmd/mmctl/commands/plugin_marketplace_e2e_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestPluginMarketplaceInstallCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("install a plugin", func(c client.Client) { + printer.Clean() + + marketPlacePlugins, appErr := s.th.App.GetMarketplacePlugins(&model.MarketplacePluginFilter{ + Page: 0, + PerPage: 100, + Filter: "jira", + }) + 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 = "jira" + ) + + 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(), "Plugin is not installed.") + } +} + +func (s *MmctlE2ETestSuite) TestPluginMarketplaceListCmd() { + s.SetupTestHelper().InitBasic() + + 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()) + }) +} diff --git a/server/cmd/mmctl/commands/plugin_marketplace_test.go b/server/cmd/mmctl/commands/plugin_marketplace_test.go new file mode 100644 index 0000000000..b32ea9df39 --- /dev/null +++ b/server/cmd/mmctl/commands/plugin_marketplace_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func createMarketplacePlugin(name string) *model.MarketplacePlugin { + return &model.MarketplacePlugin{ + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + Manifest: &model.Manifest{Name: name}, + }, + } +} + +func (s *MmctlUnitTestSuite) TestPluginMarketplaceInstallCmd() { + s.Run("Install a valid plugin", func() { + printer.Clean() + + id := "myplugin" + args := []string{id} + pluginRequest := &model.InstallMarketplacePluginRequest{Id: id} + manifest := &model.Manifest{Name: "My Plugin", Id: id} + + s.client. + EXPECT(). + InstallMarketplacePlugin(pluginRequest). + Return(manifest, &model.Response{}, nil). + Times(1) + + err := pluginMarketplaceInstallCmdF(s.client, &cobra.Command{}, args) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(manifest, printer.GetLines()[0]) + }) + + s.Run("Install an invalid plugin", func() { + printer.Clean() + + id := "myplugin" + args := []string{id} + pluginRequest := &model.InstallMarketplacePluginRequest{Id: id} + + s.client. + EXPECT(). + InstallMarketplacePlugin(pluginRequest). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + err := pluginMarketplaceInstallCmdF(s.client, &cobra.Command{}, args) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginMarketplaceListCmd() { + s.Run("List honoring pagination flags", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("page", 0, "") + cmd.Flags().Int("per-page", 1, "") + pluginFilter := &model.MarketplacePluginFilter{Page: 0, PerPage: 1} + mockPlugin := createMarketplacePlugin("My Plugin") + plugins := []*model.MarketplacePlugin{mockPlugin} + + s.client. + EXPECT(). + GetMarketplacePlugins(pluginFilter). + Return(plugins, &model.Response{}, nil). + Times(1) + + err := pluginMarketplaceListCmdF(s.client, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockPlugin, printer.GetLines()[0]) + }) + + s.Run("List all plugins", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("per-page", 1, "") + cmd.Flags().Bool("all", true, "") + mockPlugin1 := createMarketplacePlugin("My Plugin One") + mockPlugin2 := createMarketplacePlugin("My Plugin Two") + + s.client. + EXPECT(). + GetMarketplacePlugins(&model.MarketplacePluginFilter{Page: 0, PerPage: 1}). + Return([]*model.MarketplacePlugin{mockPlugin1}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetMarketplacePlugins(&model.MarketplacePluginFilter{Page: 1, PerPage: 1}). + Return([]*model.MarketplacePlugin{mockPlugin2}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetMarketplacePlugins(&model.MarketplacePluginFilter{Page: 2, PerPage: 1}). + Return([]*model.MarketplacePlugin{}, &model.Response{}, nil). + Times(1) + + err := pluginMarketplaceListCmdF(s.client, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(mockPlugin1, printer.GetLines()[0]) + s.Require().Equal(mockPlugin2, printer.GetLines()[1]) + }) + + s.Run("List all plugins with errors", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("per-page", 200, "") + + s.client. + EXPECT(). + GetMarketplacePlugins(&model.MarketplacePluginFilter{Page: 0, PerPage: 200}). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + err := pluginMarketplaceListCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("List honoring filter and local only flags", func() { + printer.Clean() + + filter := "jit" + cmd := &cobra.Command{} + cmd.Flags().Int("per-page", 200, "") + cmd.Flags().String("filter", filter, "") + cmd.Flags().Bool("local-only", true, "") + pluginFilter := &model.MarketplacePluginFilter{Page: 0, PerPage: 200, Filter: filter, LocalOnly: true} + mockPlugin := createMarketplacePlugin("Jitsi") + plugins := []*model.MarketplacePlugin{mockPlugin} + + s.client. + EXPECT(). + GetMarketplacePlugins(pluginFilter). + Return(plugins, &model.Response{}, nil). + Times(1) + + err := pluginMarketplaceListCmdF(s.client, cmd, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockPlugin, printer.GetLines()[0]) + }) +} diff --git a/server/cmd/mmctl/commands/plugin_test.go b/server/cmd/mmctl/commands/plugin_test.go new file mode 100644 index 0000000000..41c9516f31 --- /dev/null +++ b/server/cmd/mmctl/commands/plugin_test.go @@ -0,0 +1,620 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "net/http" + "os" + "strings" + + "github.com/golang/mock/gomock" + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestPluginAddCmd() { + s.Run("Add 1 plugin", func() { + printer.Clean() + tmpFile, err := ioutil.TempFile("", "tmpPlugin") + s.Require().Nil(err) + defer os.Remove(tmpFile.Name()) + + pluginName := tmpFile.Name() + + s.client. + EXPECT(). + UploadPlugin(gomock.AssignableToTypeOf(tmpFile)). + Return(&model.Manifest{}, &model.Response{}, nil). + Times(1) + + err = pluginAddCmdF(s.client, &cobra.Command{}, []string{pluginName}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Added plugin: "+pluginName) + }) + + s.Run("Add 1 plugin, with force active", func() { + printer.Clean() + tmpFile, err := ioutil.TempFile("", "tmpPlugin") + s.Require().Nil(err) + defer os.Remove(tmpFile.Name()) + + pluginName := tmpFile.Name() + + s.client. + EXPECT(). + UploadPluginForced(gomock.AssignableToTypeOf(tmpFile)). + Return(&model.Manifest{}, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", true, "") + + err = pluginAddCmdF(s.client, cmd, []string{pluginName}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Added plugin: "+pluginName) + }) + + s.Run("Add 1 plugin no file", func() { + printer.Clean() + err := pluginAddCmdF(s.client, &cobra.Command{}, []string{"non_existent_plugin"}) + s.Require().NotNil(err) + s.Require().True(err.Error() == "open non_existent_plugin: no such file or directory" || err.Error() == "open non_existent_plugin: The system cannot find the file specified.") + }) + + s.Run("Add 1 plugin with error", func() { + printer.Clean() + tmpFile, err := ioutil.TempFile("", "tmpPlugin") + s.Require().Nil(err) + defer os.Remove(tmpFile.Name()) + + pluginName := tmpFile.Name() + mockError := errors.New("plugin add error") + + s.client. + EXPECT(). + UploadPlugin(gomock.AssignableToTypeOf(tmpFile)). + Return(&model.Manifest{}, &model.Response{}, mockError). + Times(1) + + err = pluginAddCmdF(s.client, &cobra.Command{}, []string{pluginName}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to add plugin: "+pluginName+". Error: "+mockError.Error()) + }) + + s.Run("Add several plugins with some error", func() { + printer.Clean() + args := []string{"fail", "ok", "fail"} + mockError := errors.New("plugin add error") + + for idx, arg := range args { + tmpFile, err := ioutil.TempFile("", "tmpPlugin") + s.Require().Nil(err) + defer os.Remove(tmpFile.Name()) + if arg == "fail" { + s.client. + EXPECT(). + UploadPlugin(gomock.AssignableToTypeOf(tmpFile)). + Return(nil, &model.Response{}, mockError). + Times(1) + } else { + s.client. + EXPECT(). + UploadPlugin(gomock.AssignableToTypeOf(tmpFile)). + Return(&model.Manifest{}, &model.Response{}, nil). + Times(1) + } + args[idx] = tmpFile.Name() + } + + err := pluginAddCmdF(s.client, &cobra.Command{}, args) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Added plugin: "+args[1]) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to add plugin: "+args[0]+". Error: "+mockError.Error()) + s.Require().Equal(printer.GetErrorLines()[1], "Unable to add plugin: "+args[2]+". Error: "+mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginInstallUrlCmd() { + s.Run("Install multiple plugins", func() { + printer.Clean() + + pluginURL1 := "https://example.com/plugin1.tar.gz" + pluginURL2 := "https://example.com/plugin2.tar.gz" + manifest1 := &model.Manifest{Name: "plugin one"} + manifest2 := &model.Manifest{Name: "plugin two"} + args := []string{pluginURL1, pluginURL2} + + s.client. + EXPECT(). + InstallPluginFromURL(pluginURL1, false). + Return(manifest1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + InstallPluginFromURL(pluginURL2, false). + Return(manifest2, &model.Response{}, nil). + Times(1) + + err := pluginInstallURLCmdF(s.client, &cobra.Command{}, args) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(manifest1, printer.GetLines()[0]) + s.Require().Equal(manifest2, printer.GetLines()[1]) + }) + + s.Run("Install one plugin, with force active", func() { + printer.Clean() + + pluginURL := "https://example.com/plugin.tar.gz" + manifest := &model.Manifest{Name: "plugin name"} + + s.client. + EXPECT(). + InstallPluginFromURL(pluginURL, true). + Return(manifest, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", true, "") + + err := pluginInstallURLCmdF(s.client, cmd, []string{pluginURL}) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(manifest, printer.GetLines()[0]) + }) + + s.Run("Install multiple plugins, some failing", func() { + printer.Clean() + + pluginURL1 := "https://example.com/plugin1.tar.gz" + pluginURL2 := "https://example.com/plugin2.tar.gz" + manifest1 := &model.Manifest{Name: "plugin one"} + args := []string{pluginURL1, pluginURL2} + + s.client. + EXPECT(). + InstallPluginFromURL(pluginURL1, false). + Return(manifest1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + InstallPluginFromURL(pluginURL2, false). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + var expected error + expected = multierror.Append(expected, errors.New("mock error")) + + err := pluginInstallURLCmdF(s.client, &cobra.Command{}, args) + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to install plugin from URL \"https://example.com/plugin2.tar.gz\". Error: mock error", printer.GetErrorLines()[0]) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(manifest1, printer.GetLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginDisableCmd() { + s.Run("Disable 1 plugin", func() { + printer.Clean() + arg := "plug1" + + s.client. + EXPECT(). + DisablePlugin(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := pluginDisableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Disabled plugin: "+arg) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Fail to disable 1 plugin", func() { + printer.Clean() + arg := "fail1" + mockError := errors.New("mock error") + + s.client. + EXPECT(). + DisablePlugin(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := pluginDisableCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+arg+". Error: "+mockError.Error()) + }) + + s.Run("Disble several plugin with some errors", func() { + printer.Clean() + args := []string{"fail1", "plug2", "plug3", "fail4"} + mockError := errors.New("mock error") + + for _, arg := range args { + if strings.HasPrefix(arg, "fail") { + s.client. + EXPECT(). + DisablePlugin(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + } else { + s.client. + EXPECT(). + DisablePlugin(arg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + } + } + + err := pluginDisableCmdF(s.client, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], "Disabled plugin: "+args[1]) + s.Require().Equal(printer.GetLines()[1], "Disabled plugin: "+args[2]) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to disable plugin: "+args[0]+". Error: "+mockError.Error()) + s.Require().Equal(printer.GetErrorLines()[1], "Unable to disable plugin: "+args[3]+". Error: "+mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginEnableCmd() { + s.Run("Enable 1 plugin", func() { + printer.Clean() + pluginArg := "test-plugin" + + s.client. + EXPECT(). + EnablePlugin(pluginArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := pluginEnableCmdF(s.client, &cobra.Command{}, []string{pluginArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+pluginArg) + }) + + s.Run("Enable multiple plugins", func() { + printer.Clean() + plugins := []string{"plugin1", "plugin2", "plugin3"} + + for _, plugin := range plugins { + s.client. + EXPECT(). + EnablePlugin(plugin). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + } + + err := pluginEnableCmdF(s.client, &cobra.Command{}, plugins) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 3) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+plugins[0]) + s.Require().Equal(printer.GetLines()[1], "Enabled plugin: "+plugins[1]) + s.Require().Equal(printer.GetLines()[2], "Enabled plugin: "+plugins[2]) + }) + + s.Run("Fail to enable plugin", func() { + printer.Clean() + pluginArg := "fail-plugin" + mockErr := errors.New("mock error") + + s.client. + EXPECT(). + EnablePlugin(pluginArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr). + Times(1) + + err := pluginEnableCmdF(s.client, &cobra.Command{}, []string{pluginArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to enable plugin: "+pluginArg+". Error: "+mockErr.Error()) + }) + + s.Run("Enable multiple plugins with some having errors", func() { + printer.Clean() + okPlugins := []string{"ok-plugin-1", "ok-plugin-2"} + failPlugins := []string{"fail-plugin-1", "fail-plugin-2"} + allPlugins := okPlugins + allPlugins = append(allPlugins, failPlugins...) + + mockErr := errors.New("mock error") + + for _, plugin := range okPlugins { + s.client. + EXPECT(). + EnablePlugin(plugin). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + } + + for _, plugin := range failPlugins { + s.client. + EXPECT(). + EnablePlugin(plugin). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErr). + Times(1) + } + + err := pluginEnableCmdF(s.client, &cobra.Command{}, allPlugins) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(printer.GetLines()[0], "Enabled plugin: "+okPlugins[0]) + s.Require().Equal(printer.GetLines()[1], "Enabled plugin: "+okPlugins[1]) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal(printer.GetErrorLines()[0], "Unable to enable plugin: "+failPlugins[0]+". Error: "+mockErr.Error()) + s.Require().Equal(printer.GetErrorLines()[1], "Unable to enable plugin: "+failPlugins[1]+". Error: "+mockErr.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginListCmd() { + s.Run("List JSON plugins", func() { + printer.Clean() + mockList := &model.PluginsResponse{ + Active: []*model.PluginInfo{ + { + Manifest: model.Manifest{ + Id: "id1", + Name: "name1", + Version: "v1", + }, + }, + { + Manifest: model.Manifest{ + Id: "id2", + Name: "name2", + Version: "v2", + }, + }, + { + Manifest: model.Manifest{ + Id: "id3", + Name: "name3", + Version: "v3", + }, + }, + }, Inactive: []*model.PluginInfo{ + { + Manifest: model.Manifest{ + Id: "id4", + Name: "name4", + Version: "v4", + }, + }, + { + Manifest: model.Manifest{ + Id: "id5", + Name: "name5", + Version: "v5", + }, + }, + { + Manifest: model.Manifest{ + Id: "id6", + Name: "name6", + Version: "v6", + }, + }, + }, + } + + s.client. + EXPECT(). + GetPlugins(). + Return(mockList, &model.Response{}, nil). + Times(1) + + err := pluginListCmdF(s.client, &cobra.Command{}, nil) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 8) + + s.Require().Equal("Listing enabled plugins", printer.GetLines()[0]) + for i, plugin := range mockList.Active { + s.Require().Equal(plugin, printer.GetLines()[i+1]) + } + + s.Require().Equal("Listing disabled plugins", printer.GetLines()[4]) + for i, plugin := range mockList.Inactive { + s.Require().Equal(plugin, printer.GetLines()[i+5]) + } + }) + + s.Run("List Plain Plugins", func() { + printer.Clean() + printer.SetFormat(printer.FormatPlain) + defer printer.SetFormat(printer.FormatJSON) + + mockList := &model.PluginsResponse{ + Active: []*model.PluginInfo{ + { + Manifest: model.Manifest{ + Id: "id1", + Name: "name1", + Version: "v1", + }, + }, + { + Manifest: model.Manifest{ + Id: "id2", + Name: "name2", + Version: "v2", + }, + }, + { + Manifest: model.Manifest{ + Id: "id3", + Name: "name3", + Version: "v3", + }, + }, + }, Inactive: []*model.PluginInfo{ + { + Manifest: model.Manifest{ + Id: "id4", + Name: "name4", + Version: "v4", + }, + }, + { + Manifest: model.Manifest{ + Id: "id5", + Name: "name5", + Version: "v5", + }, + }, + { + Manifest: model.Manifest{ + Id: "id6", + Name: "name6", + Version: "v6", + }, + }, + }, + } + + s.client. + EXPECT(). + GetPlugins(). + Return(mockList, &model.Response{}, nil). + Times(1) + + err := pluginListCmdF(s.client, &cobra.Command{}, nil) + s.Require().NoError(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 8) + + s.Require().Equal("Listing enabled plugins", printer.GetLines()[0]) + for i, plugin := range mockList.Active { + s.Require().Equal(plugin.Id+": "+plugin.Name+", Version: "+plugin.Version, printer.GetLines()[i+1]) + } + + s.Require().Equal("Listing disabled plugins", printer.GetLines()[4]) + for i, plugin := range mockList.Inactive { + s.Require().Equal(plugin.Id+": "+plugin.Name+", Version: "+plugin.Version, printer.GetLines()[i+5]) + } + }) + + s.Run("GetPlugins returns error", func() { + printer.Clean() + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetPlugins(). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := pluginListCmdF(s.client, &cobra.Command{}, nil) + s.Require().NotNil(err) + s.Require().EqualError(err, "Unable to list plugins. Error: "+mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestPluginDeleteCmd() { + s.Run("Delete one plugin with error", func() { + printer.Clean() + args := "plugin" + mockError := errors.New("mock error") + + s.client. + EXPECT(). + RemovePlugin(args). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := pluginDeleteCmdF(s.client, &cobra.Command{}, []string{args}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to delete plugin: "+args+". Error: "+mockError.Error(), printer.GetErrorLines()[0]) + }) + + s.Run("Delete one plugin with no error", func() { + printer.Clean() + args := "plugin" + + s.client. + EXPECT(). + RemovePlugin(args). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := pluginDeleteCmdF(s.client, &cobra.Command{}, []string{args}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("Deleted plugin: "+args, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Delete several plugins", func() { + printer.Clean() + args := []string{ + "plugin0", + "error1", + "error2", + "plugin3", + } + mockErrors := []error{ + errors.New("mock error1"), + errors.New("mock error2"), + } + + s.client. + EXPECT(). + RemovePlugin(args[0]). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + RemovePlugin(args[1]). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErrors[0]). + Times(1) + + s.client. + EXPECT(). + RemovePlugin(args[2]). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockErrors[1]). + Times(1) + + s.client. + EXPECT(). + RemovePlugin(args[3]). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := pluginDeleteCmdF(s.client, &cobra.Command{}, args) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal("Deleted plugin: "+args[0], printer.GetLines()[0]) + s.Require().Equal("Deleted plugin: "+args[3], printer.GetLines()[1]) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal("Unable to delete plugin: "+args[1]+". Error: "+mockErrors[0].Error(), printer.GetErrorLines()[0]) + s.Require().Equal("Unable to delete plugin: "+args[2]+". Error: "+mockErrors[1].Error(), printer.GetErrorLines()[1]) + }) +} diff --git a/server/cmd/mmctl/commands/post.go b/server/cmd/mmctl/commands/post.go new file mode 100644 index 0000000000..25fb2d8b36 --- /dev/null +++ b/server/cmd/mmctl/commands/post.go @@ -0,0 +1,218 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var PostCmd = &cobra.Command{ + Use: "post", + Short: "Management of posts", +} + +var PostCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a post", + Example: ` post create myteam:mychannel --message "some text for the post"`, + Args: cobra.ExactArgs(1), + RunE: withClient(postCreateCmdF), +} + +var PostListCmd = &cobra.Command{ + Use: "list", + Short: "List posts for a channel", + Example: ` post list myteam:mychannel + post list myteam:mychannel --number 20`, + Args: cobra.ExactArgs(1), + RunE: withClient(postListCmdF), +} + +const ( + ISO8601Layout = "2006-01-02T15:04:05-07:00" + PostTimeFormat = "2006-01-02 15:04:05-07:00" +) + +func init() { + PostCreateCmd.Flags().StringP("message", "m", "", "Message for the post") + PostCreateCmd.Flags().StringP("reply-to", "r", "", "Post id to reply to") + + PostListCmd.Flags().IntP("number", "n", 20, "Number of messages to list") + PostListCmd.Flags().BoolP("show-ids", "i", false, "Show posts ids") + PostListCmd.Flags().BoolP("follow", "f", false, "Output appended data as new messages are posted to the channel") + PostListCmd.Flags().StringP("since", "s", "", "List messages posted after a certain time (ISO 8601)") + + PostCmd.AddCommand( + PostCreateCmd, + PostListCmd, + ) + + RootCmd.AddCommand(PostCmd) +} + +func postCreateCmdF(c client.Client, cmd *cobra.Command, args []string) error { + message, _ := cmd.Flags().GetString("message") + if message == "" { + return errors.New("message cannot be empty") + } + + replyTo, _ := cmd.Flags().GetString("reply-to") + if replyTo != "" { + replyToPost, _, err := c.GetPost(replyTo, "") + if err != nil { + return err + } + if replyToPost.RootId != "" { + replyTo = replyToPost.RootId + } + } + + channel := getChannelFromChannelArg(c, args[0]) + if channel == nil { + return errors.New("Unable to find channel '" + args[0] + "'") + } + + post := &model.Post{ + ChannelId: channel.Id, + Message: message, + RootId: replyTo, + } + + url := "/posts" + "?set_online=false" + data, err := post.ToJSON() + if err != nil { + return fmt.Errorf("could not decode post: %w", err) + } + + if _, err := c.DoAPIPost(url, data); err != nil { + return fmt.Errorf("could not create post: %s", err.Error()) + } + return nil +} + +func eventDataToPost(eventData map[string]interface{}) (*model.Post, error) { + post := &model.Post{} + var rawPost string + for k, v := range eventData { + if k == "post" { + rawPost = v.(string) + } + } + + err := json.Unmarshal([]byte(rawPost), &post) + if err != nil { + return nil, err + } + return post, nil +} + +func printPost(c client.Client, post *model.Post, usernames map[string]string, showIds, showTimestamp bool) { + var username string + + if usernames[post.UserId] != "" { + username = usernames[post.UserId] + } else { + user, _, err := c.GetUser(post.UserId, "") + if err != nil { + username = post.UserId + } else { + usernames[post.UserId] = user.Username + username = user.Username + } + } + + postTime := model.GetTimeForMillis(post.CreateAt) + createdAt := postTime.Format(PostTimeFormat) + + if showTimestamp { + printer.PrintT(fmt.Sprintf("\u001b[32m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", createdAt, username), post) + } else { + if showIds { + printer.PrintT(fmt.Sprintf("\u001b[31m%s\u001b[0m \u001b[34;1m[%s]\u001b[0m {{.Message}}", post.Id, username), post) + } else { + printer.PrintT(fmt.Sprintf("\u001b[34;1m[%s]\u001b[0m {{.Message}}", username), post) + } + } +} + +func getPostList(client client.Client, channelID, since string, perPage int) (*model.PostList, *model.Response, error) { + if since == "" { + return client.GetPostsForChannel(channelID, 0, perPage, "", false, false) + } + + sinceTime, err := time.Parse(ISO8601Layout, since) + if err != nil { + return nil, nil, fmt.Errorf("invalid since time '%s'", since) + } + + sinceTimeMillis := model.GetMillisForTime(sinceTime) + return client.GetPostsSince(channelID, sinceTimeMillis, false) +} + +func postListCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + channel := getChannelFromChannelArg(c, args[0]) + if channel == nil { + return errors.New("Unable to find channel '" + args[0] + "'") + } + + number, _ := cmd.Flags().GetInt("number") + showIds, _ := cmd.Flags().GetBool("show-ids") + follow, _ := cmd.Flags().GetBool("follow") + since, _ := cmd.Flags().GetString("since") + + postList, _, err := getPostList(c, channel.Id, since, number) + if err != nil { + return err + } + + posts := postList.ToSlice() + showTimestamp := since != "" + usernames := map[string]string{} + for i := 1; i <= len(posts); i++ { + post := posts[len(posts)-i] + printPost(c, post, usernames, showIds, showTimestamp) + } + + var multiErr *multierror.Error + if follow { + ws, err := InitWebSocketClient() + if err != nil { + return err + } + + appErr := ws.Connect() + if appErr != nil { + return errors.New(appErr.Error()) + } + + ws.Listen() + for { + event := <-ws.EventChannel + if event.EventType() == model.WebsocketEventPosted { + post, err := eventDataToPost(event.GetData()) + if err != nil { + printer.PrintError("Error parsing incoming post: " + err.Error()) + multiErr = multierror.Append(multiErr, err) + } + if post.ChannelId == channel.Id { + printPost(c, post, usernames, showIds, showTimestamp) + } + } + } + } + return multiErr.ErrorOrNil() +} diff --git a/server/cmd/mmctl/commands/post_e2e_test.go b/server/cmd/mmctl/commands/post_e2e_test.go new file mode 100644 index 0000000000..d556495cc3 --- /dev/null +++ b/server/cmd/mmctl/commands/post_e2e_test.go @@ -0,0 +1,194 @@ +// 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/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" +) + +func (s *MmctlE2ETestSuite) TestPostListCmd() { + s.SetupTestHelper().InitBasic() + + var createNewChannelAndPosts = func() (string, *model.Post, *model.Post) { + channelName := model.NewRandomString(10) + channelDisplayName := "channelDisplayName" + + channel, err := s.th.App.CreateChannel(s.th.Context, &model.Channel{Name: channelName, DisplayName: channelDisplayName, Type: model.ChannelTypeOpen, TeamId: s.th.BasicTeam.Id}, false) + s.Require().Nil(err) + + post1, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, false, false) + s.Require().Nil(err) + + post2, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, false, false) + s.Require().Nil(err) + + return channelName, post1, post2 + } + + s.RunForSystemAdminAndLocal("List all posts for a channel", func(c client.Client) { + printer.Clean() + + teamName := s.th.BasicTeam.Name + channelName, post1, post2 := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 2, "") + + err := postListCmdF(c, cmd, []string{teamName + ":" + channelName}) + s.Require().Nil(err) + s.Equal(2, len(printer.GetLines())) + + printedPost1, ok := printer.GetLines()[0].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost1.Message, post1.Message) + + printedPost2, ok := printer.GetLines()[1].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost2.Message, post2.Message) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List all posts for a channel without permissions", func() { + printer.Clean() + + teamName := s.th.BasicTeam.Name + channelName, _, _ := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 2, "") + + err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "You do not have the appropriate permissions.") + }) + + s.RunForSystemAdminAndLocal("List all posts for a channel with since flag", func(c client.Client) { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + teamName := s.th.BasicTeam.Name + channelName, post1, post2 := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().String("since", ISO8601ValidString, "") + + err := postListCmdF(c, cmd, []string{teamName + ":" + channelName}) + s.Require().Nil(err) + s.Equal(2, len(printer.GetLines())) + + printedPost1, ok := printer.GetLines()[0].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost1.Message, post1.Message) + + printedPost2, ok := printer.GetLines()[1].(*model.Post) + s.Require().True(ok) + s.Require().Equal(printedPost2.Message, post2.Message) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("List all posts for a channel with since flag without permissions", func() { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + teamName := s.th.BasicTeam.Name + channelName, _, _ := createNewChannelAndPosts() + + cmd := &cobra.Command{} + cmd.Flags().String("since", ISO8601ValidString, "") + + err := postListCmdF(s.th.Client, cmd, []string{teamName + ":" + channelName}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "You do not have the appropriate permissions.") + }) +} + +func (s *MmctlE2ETestSuite) TestPostCreateCmd() { + s.SetupTestHelper().InitBasic() + + s.Run("Create a post for System Admin Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a post for Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a post for Local Client should fail", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().NotNil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for System Admin Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.SystemAdminClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for Client", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reply to a an existing post for Local Client should fail", func() { + printer.Clean() + + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", s.th.BasicPost.Id, "") + + err := postCreateCmdF(s.th.LocalClient, cmd, []string{s.th.BasicTeam.Name + ":" + s.th.BasicChannel.Name}) + s.Require().NotNil(err) + s.Len(printer.GetErrorLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/post_test.go b/server/cmd/mmctl/commands/post_test.go new file mode 100644 index 0000000000..c9cf1ff266 --- /dev/null +++ b/server/cmd/mmctl/commands/post_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlUnitTestSuite) TestPostCreateCmdF() { + s.Run("create a post with empty text", func() { + cmd := &cobra.Command{} + + err := postCreateCmdF(s.client, cmd, []string{"some-channel", ""}) + s.Require().EqualError(err, "message cannot be empty") + }) + + s.Run("no channel specified", func() { + msgArg := "some text" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + err := postCreateCmdF(s.client, cmd, []string{"", msgArg}) + s.Require().EqualError(err, "Unable to find channel ''") + }) + + s.Run("wrong reply msg", func() { + msgArg := "some text" + replyToArg := "a-non-existing-post" + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + cmd.Flags().String("reply-to", replyToArg, "") + + s.client. + EXPECT(). + GetPost(replyToArg, ""). + Return(nil, &model.Response{}, errors.New("some-error")). + Times(1) + + err := postCreateCmdF(s.client, cmd, []string{msgArg}) + s.Require().Contains(err.Error(), "some-error") + }) + + s.Run("error when creating a post", func() { + msgArg := "some text" + channelArg := "example-channel" + mockChannel := model.Channel{Name: channelArg} + mockPost := &model.Post{Message: msgArg} + data, err := mockPost.ToJSON() + s.Require().NoError(err) + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DoAPIPost("/posts?set_online=false", data). + Return(nil, errors.New("some-error")). + Times(1) + + err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg}) + s.Require().Contains(err.Error(), "could not create post") + }) + + s.Run("create a post", func() { + msgArg := "some text" + channelArg := "example-channel" + mockChannel := model.Channel{Name: channelArg} + mockPost := model.Post{Message: msgArg} + data, err := mockPost.ToJSON() + s.Require().NoError(err) + + cmd := &cobra.Command{} + cmd.Flags().String("message", msgArg, "") + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DoAPIPost("/posts?set_online=false", data). + Return(nil, nil). + Times(1) + + err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("reply to an existing post", func() { + msgArg := "some text" + replyToArg := "an-existing-post" + rootID := "some-root-id" + channelArg := "example-channel" + mockChannel := model.Channel{Name: channelArg} + mockReplyTo := model.Post{RootId: rootID} + mockPost := model.Post{Message: msgArg, RootId: rootID} + data, err := mockPost.ToJSON() + s.Require().NoError(err) + + cmd := &cobra.Command{} + cmd.Flags().String("reply-to", replyToArg, "") + cmd.Flags().String("message", msgArg, "") + + s.client. + EXPECT(). + GetChannel(channelArg, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPost(replyToArg, ""). + Return(&mockReplyTo, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DoAPIPost("/posts?set_online=false", data). + Return(nil, nil). + Times(1) + + err = postCreateCmdF(s.client, cmd, []string{channelArg, msgArg}) + s.Require().Nil(err) + s.Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestPostListCmdF() { + s.Run("no channel specified", func() { + sinceArg := "invalid-date" + + cmd := &cobra.Command{} + cmd.Flags().String("since", sinceArg, "") + + err := postListCmdF(s.client, cmd, []string{"", sinceArg}) + s.Require().EqualError(err, "Unable to find channel ''") + }) + + s.Run("invalid time for since flag", func() { + sinceArg := "invalid-date" + mockChannel := model.Channel{Name: channelName} + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("since", sinceArg, "") + + err := postListCmdF(s.client, cmd, []string{channelName, sinceArg}) + s.Require().Contains(err.Error(), "invalid since time 'invalid-date'") + }) + + s.Run("list posts for a channel", func() { + printer.Clean() + mockChannel := model.Channel{Name: channelName, Id: channelID} + mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID, CreateAt: model.GetMillisForTime(time.Now())} + mockPostList := model.NewPostList() + mockPostList.AddPost(mockPost) + mockPostList.AddOrder(mockPost.Id) + mockUser := model.User{Id: userID, Username: "some-user"} + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 1, "") + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPostsForChannel(channelID, 0, 1, "", false, false). + Return(mockPostList, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(userID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + printer.Clean() + err := postListCmdF(s.client, cmd, []string{channelName}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], mockPost) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("list posts for a channel from a certain time (valid date)", func() { + printer.Clean() + + ISO8601ValidString := "2006-01-02T15:04:05-07:00" + + sinceArg := "2006-01-02T15:04:05-07:00" + sinceTime, err := time.Parse(ISO8601ValidString, sinceArg) + s.Require().Nil(err) + + sinceTimeMillis := model.GetMillisForTime(sinceTime) + + mockChannel := model.Channel{Name: channelName, Id: channelID} + mockPost := &model.Post{Message: "some text", Id: "some-id", UserId: userID} + mockPostList := model.NewPostList() + mockPostList.AddPost(mockPost) + mockPostList.AddOrder(mockPost.Id) + mockUser := model.User{Id: userID, Username: "some-user"} + + cmd := &cobra.Command{} + cmd.Flags().Int("number", 1, "") + cmd.Flags().String("since", sinceArg, "") + + s.client. + EXPECT(). + GetChannel(channelName, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetPostsSince(channelID, sinceTimeMillis, false). + Return(mockPostList, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(userID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err = postListCmdF(s.client, cmd, []string{channelName}) + s.Require().Nil(err) + s.Require().Equal(printer.GetLines()[0], mockPost) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/roles.go b/server/cmd/mmctl/commands/roles.go new file mode 100644 index 0000000000..ba6c268a8e --- /dev/null +++ b/server/cmd/mmctl/commands/roles.go @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +var RolesCmd = &cobra.Command{ + Use: "roles", + Short: "Manage user roles", +} + +var RolesSystemAdminCmd = &cobra.Command{ + Use: "system-admin [users]", + Aliases: []string{"system_admin"}, + Short: "Set a user as system admin", + Long: "Make some users system admins.", + Example: ` # You can make one user a sysadmin + $ mmctl roles system-admin john_doe + + # Or promote multiple users at the same time + $ mmctl roles system-admin john_doe jane_doe`, + RunE: withClient(rolesSystemAdminCmdF), + Args: cobra.MinimumNArgs(1), +} + +var RolesMemberCmd = &cobra.Command{ + Use: "member [users]", + Short: "Remove system admin privileges", + Long: "Remove system admin privileges from some users.", + Example: ` # You can remove admin privileges from one user + $ mmctl roles member john_doe + + # Or demote multiple users at the same time + $ mmctl roles member john_doe jane_doe`, + RunE: withClient(rolesMemberCmdF), + Args: cobra.MinimumNArgs(1), +} + +func init() { + RolesCmd.AddCommand( + RolesSystemAdminCmd, + RolesMemberCmd, + ) + + RootCmd.AddCommand(RolesCmd) +} + +func rolesSystemAdminCmdF(c client.Client, _ *cobra.Command, args []string) error { + var errs *multierror.Error + users := getUsersFromUserArgs(c, args) + for i, user := range users { + if user == nil { + userErr := fmt.Errorf("unable to find user %q", args[i]) + errs = multierror.Append(errs, userErr) + printer.PrintError(userErr.Error()) + continue + } + + systemAdmin := false + roles := strings.Fields(user.Roles) + for _, role := range roles { + if role == model.SystemAdminRoleId { + systemAdmin = true + } + } + + if !systemAdmin { + roles = append(roles, model.SystemAdminRoleId) + if _, err := c.UpdateUserRoles(user.Id, strings.Join(roles, " ")); err != nil { + updateErr := fmt.Errorf("can't update roles for user %q: %w", args[i], err) + errs = multierror.Append(errs, updateErr) + printer.PrintError(updateErr.Error()) + continue + } + + printer.Print(fmt.Sprintf("System admin role assigned to user %q. Current roles are: %s", args[i], strings.Join(roles, ", "))) + } + } + + return errs.ErrorOrNil() +} + +func rolesMemberCmdF(c client.Client, _ *cobra.Command, args []string) error { + var errs *multierror.Error + users := getUsersFromUserArgs(c, args) + for i, user := range users { + if user == nil { + userErr := fmt.Errorf("unable to find user %q", args[i]) + errs = multierror.Append(errs, userErr) + printer.PrintError(userErr.Error()) + continue + } + + shouldRemoveSysadmin := false + var newRoles []string + + roles := strings.Fields(user.Roles) + for _, role := range roles { + switch role { + case model.SystemAdminRoleId: + shouldRemoveSysadmin = true + default: + newRoles = append(newRoles, role) + } + } + + if shouldRemoveSysadmin { + if _, err := c.UpdateUserRoles(user.Id, strings.Join(newRoles, " ")); err != nil { + updateErr := fmt.Errorf("can't update roles for user %q: %w", args[i], err) + errs = multierror.Append(errs, updateErr) + printer.PrintError(updateErr.Error()) + continue + } + + printer.Print(fmt.Sprintf("System admin role revoked for user %q. Current roles are: %s", args[i], strings.Join(newRoles, ", "))) + } + } + + return errs.ErrorOrNil() +} diff --git a/server/cmd/mmctl/commands/roles_test.go b/server/cmd/mmctl/commands/roles_test.go new file mode 100644 index 0000000000..a71644ee4f --- /dev/null +++ b/server/cmd/mmctl/commands/roles_test.go @@ -0,0 +1,221 @@ +// 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/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestMakeAdminCmd() { + s.Run("Add admin privileges to user", func() { + printer.Clean() + + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"} + newRoles := "system_user system_admin" + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, newRoles). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().Nil(err) + + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(fmt.Sprintf("System admin role assigned to user %q. Current roles are: %s", mockUser.Email, "system_user, system_admin"), printer.GetLines()[0]) + }) + + s.Run("Adding admin privileges to existing admin", func() { + printer.Clean() + + roles := "system_user system_admin" + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: roles} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().Nil(err) + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Add admin to non existing user", func() { + printer.Clean() + + emailArg := "doesnotexist@example.com" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().ErrorContains(err, "unable to find user") + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to find user %q", emailArg), printer.GetErrorLines()[0]) + }) + + s.Run("Error while updating admin role", func() { + printer.Clean() + + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"} + newRoles := "system_user system_admin" + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, newRoles). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := rolesSystemAdminCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().ErrorContains(err, "can't update roles for user") + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("can't update roles for user %q", mockUser.Email)) + }) +} + +func (s *MmctlUnitTestSuite) TestMakeMemberCmd() { + s.Run("Remove admin privileges for admin", func() { + printer.Clean() + + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user system_admin"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, "system_user"). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().Nil(err) + + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(fmt.Sprintf("System admin role revoked for user %q. Current roles are: %s", mockUser.Email, "system_user"), printer.GetLines()[0]) + }) + + s.Run("Remove admin privileges from non admin user", func() { + printer.Clean() + + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().Nil(err) + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error while revoking admin role", func() { + printer.Clean() + + mockUser := &model.User{Id: "1", Email: "u1@example.com", Roles: "system_user system_admin"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, "system_user"). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().ErrorContains(err, "can't update roles for user") + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Contains(printer.GetErrorLines()[0], fmt.Sprintf("can't update roles for user %q", mockUser.Email)) + }) + + s.Run("Remove admin from non existing user", func() { + printer.Clean() + + emailArg := "doesnotexist@example.com" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := rolesMemberCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().ErrorContains(err, "unable to find user") + + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to find user %q", emailArg), printer.GetErrorLines()[0]) + }) +} diff --git a/server/cmd/mmctl/commands/root.go b/server/cmd/mmctl/commands/root.go new file mode 100644 index 0000000000..f583ea77d4 --- /dev/null +++ b/server/cmd/mmctl/commands/root.go @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "os" + "path/filepath" + "runtime/debug" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func Run(args []string) error { + viper.SetEnvPrefix("mmctl") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.SetDefault("local-socket-path", model.LocalModeSocketPath) + viper.AutomaticEnv() + + RootCmd.PersistentFlags().String("config", filepath.Join(xdgConfigHomeVar, configParent, configFileName), "path to the configuration file") + _ = viper.BindPFlag("config", RootCmd.PersistentFlags().Lookup("config")) + RootCmd.PersistentFlags().String("config-path", xdgConfigHomeVar, "path to the configuration directory.") + _ = viper.BindPFlag("config-path", RootCmd.PersistentFlags().Lookup("config-path")) + _ = RootCmd.PersistentFlags().MarkHidden("config-path") + RootCmd.PersistentFlags().Bool("suppress-warnings", false, "disables printing warning messages") + _ = viper.BindPFlag("suppress-warnings", RootCmd.PersistentFlags().Lookup("suppress-warnings")) + RootCmd.PersistentFlags().String("format", "plain", "the format of the command output [plain, json]") + _ = viper.BindPFlag("format", RootCmd.PersistentFlags().Lookup("format")) + _ = RootCmd.PersistentFlags().MarkHidden("format") + RootCmd.PersistentFlags().Bool("json", false, "the output format will be in json format") + _ = viper.BindPFlag("json", RootCmd.PersistentFlags().Lookup("json")) + RootCmd.PersistentFlags().Bool("strict", false, "will only run commands if the mmctl version matches the server one") + _ = viper.BindPFlag("strict", RootCmd.PersistentFlags().Lookup("strict")) + RootCmd.PersistentFlags().Bool("insecure-sha1-intermediate", false, "allows to use insecure TLS protocols, such as SHA-1") + _ = viper.BindPFlag("insecure-sha1-intermediate", RootCmd.PersistentFlags().Lookup("insecure-sha1-intermediate")) + RootCmd.PersistentFlags().Bool("insecure-tls-version", false, "allows to use TLS versions 1.0 and 1.1") + _ = viper.BindPFlag("insecure-tls-version", RootCmd.PersistentFlags().Lookup("insecure-tls-version")) + RootCmd.PersistentFlags().Bool("local", false, "allows communicating with the server through a unix socket") + _ = viper.BindPFlag("local", RootCmd.PersistentFlags().Lookup("local")) + RootCmd.PersistentFlags().Bool("short-stat", false, "short stat will provide useful statistical data") + _ = RootCmd.PersistentFlags().MarkHidden("short-stat") + RootCmd.PersistentFlags().Bool("no-stat", false, "the statistical data won't be displayed") + _ = RootCmd.PersistentFlags().MarkHidden("no-stat") + RootCmd.PersistentFlags().Bool("disable-pager", false, "disables paged output") + _ = viper.BindPFlag("disable-pager", RootCmd.PersistentFlags().Lookup("disable-pager")) + RootCmd.PersistentFlags().Bool("quiet", false, "prevent mmctl to generate output for the commands") + _ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet")) + + RootCmd.SetArgs(args) + + defer func() { + if x := recover(); x != nil { + printer.PrintError("Uh oh! Something unexpected happened :( Would you mind reporting it?\n") + printer.PrintError(`https://github.com/mattermost/mmctl/issues/new?title=%5Bbug%5D%20panic%20on%20mmctl%20v` + Version + "&body=%3C!---%20Please%20provide%20the%20stack%20trace%20--%3E\n") + printer.PrintError(string(debug.Stack())) + + os.Exit(1) + } + }() + + return RootCmd.Execute() +} + +var RootCmd = &cobra.Command{ + Use: "mmctl", + Short: "Remote client for the Open Source, self-hosted Slack-alternative", + Long: `Mattermost offers workplace messaging across web, PC and phones with archiving, search and integration with your existing systems. Documentation available at https://docs.mattermost.com`, + DisableAutoGenTag: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + format := viper.GetString("format") + if viper.GetBool("disable-pager") { + printer.OverrideEnablePager(false) + } + + printer.SetCommand(cmd) + isJSON := viper.GetBool("json") + if isJSON || format == printer.FormatJSON { + printer.SetFormat(printer.FormatJSON) + } else { + printer.SetFormat(printer.FormatPlain) + } + quiet := viper.GetBool("quiet") + printer.SetQuiet(quiet) + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + _ = printer.Flush() + }, + SilenceUsage: true, +} diff --git a/server/cmd/mmctl/commands/saml.go b/server/cmd/mmctl/commands/saml.go new file mode 100644 index 0000000000..17df6dcd7b --- /dev/null +++ b/server/cmd/mmctl/commands/saml.go @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +var SamlCmd = &cobra.Command{ + Use: "saml", + Short: "SAML related utilities", +} + +var SamlAuthDataResetCmd = &cobra.Command{ + Use: "auth-data-reset", + Short: "Reset AuthData field to Email", + Long: "Resets the AuthData field for SAML users to their email. Run this utility after setting the 'id' SAML attribute to an empty value.", + Example: ` # Reset all SAML users' AuthData field to their email, including deleted users + $ mmctl saml auth-data-reset --include-deleted + + # Show how many users would be affected by the reset + $ mmctl saml auth-data-reset --dry-run + + # Skip confirmation for resetting the AuthData + $ mmctl saml auth-data-reset -y + + # Only reset the AuthData for the following SAML users + $ mmctl saml auth-data-reset --users userid1,userid2`, + RunE: withClient(samlAuthDataResetCmdF), +} + +func init() { + SamlAuthDataResetCmd.Flags().Bool("include-deleted", false, "Include deleted users") + SamlAuthDataResetCmd.Flags().Bool("dry-run", false, "Dry run only") + SamlAuthDataResetCmd.Flags().BoolP("yes", "y", false, "Skip confirmation") + SamlAuthDataResetCmd.Flags().StringSlice("users", nil, "Comma-separated list of user IDs to which the operation will be applied") + + SamlCmd.AddCommand( + SamlAuthDataResetCmd, + ) + RootCmd.AddCommand(SamlCmd) +} + +func samlAuthDataResetCmdF(c client.Client, cmd *cobra.Command, args []string) error { + includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + dryRun, _ := cmd.Flags().GetBool("dry-run") + confirmed, _ := cmd.Flags().GetBool("yes") + userIDs, _ := cmd.Flags().GetStringSlice("users") + + if !dryRun && !confirmed { + if err := getConfirmation("This action is irreversible. Are you sure you want to continue?", false); err != nil { + return err + } + } + + numAffected, _, err := c.ResetSamlAuthDataToEmail(includeDeleted, dryRun, userIDs) + if err != nil { + return err + } + + if dryRun { + printer.Print(fmt.Sprintf("%d user records would be affected.\n", numAffected)) + } else { + printer.Print(fmt.Sprintf("%d user records were changed.\n", numAffected)) + } + + return nil +} diff --git a/server/cmd/mmctl/commands/saml_test.go b/server/cmd/mmctl/commands/saml_test.go new file mode 100644 index 0000000000..4fb604e5da --- /dev/null +++ b/server/cmd/mmctl/commands/saml_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestSamlAuthDataReset() { + s.Run("Reset auth data without confirmation returns an error", func() { + cmd := &cobra.Command{} + err := samlAuthDataResetCmdF(s.client, cmd, nil) + s.Require().NotNil(err) + s.Require().EqualError(err, "could not proceed, either enable --confirm flag or use an interactive shell to complete operation: this is not an interactive shell") + }) + + s.Run("Reset auth data without errors", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().Bool("yes", true, "") + outputMessage := "1 user records were changed.\n" + + s.client. + EXPECT(). + ResetSamlAuthDataToEmail(false, false, []string{}). + Return(int64(1), &model.Response{}, nil). + Times(1) + + err := samlAuthDataResetCmdF(s.client, cmd, nil) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], outputMessage) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reset auth data dry run", func() { + printer.Clean() + outputMessage := "1 user records would be affected.\n" + + cmd := &cobra.Command{} + cmd.Flags().Bool("dry-run", true, "") + + s.client. + EXPECT(). + ResetSamlAuthDataToEmail(false, true, []string{}). + Return(int64(1), &model.Response{}, nil). + Times(1) + + err := samlAuthDataResetCmdF(s.client, cmd, nil) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], outputMessage) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reset auth data with specified users", func() { + printer.Clean() + users := []string{"user1"} + s.client. + EXPECT(). + ResetSamlAuthDataToEmail(false, false, users). + Return(int64(1), &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("yes", true, "") + cmd.Flags().StringSlice("users", users, "") + + err := samlAuthDataResetCmdF(s.client, cmd, nil) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/sampledata.go b/server/cmd/mmctl/commands/sampledata.go new file mode 100644 index 0000000000..8dc0c8f151 --- /dev/null +++ b/server/cmd/mmctl/commands/sampledata.go @@ -0,0 +1,413 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//nolint:gosec +package commands + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "sort" + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/v8/channels/app/imports" + "github.com/mattermost/mattermost-server/server/v8/channels/utils" + + "github.com/mattermost/mattermost-server/server/public/model" + pUtils "github.com/mattermost/mattermost-server/server/public/utils" + + "github.com/icrowley/fake" + "github.com/spf13/cobra" +) + +const ( + deactivatedUser = "deactivated" + guestUser = "guest" + attachmentsDir = "attachments" +) + +var SampledataCmd = &cobra.Command{ + Use: "sampledata", + Short: "Generate sample data", + Long: "Generate a sample data file and store it locally, or directly import it to the remote server", + Example: ` # you can create a sampledata file and store it locally + $ mmctl sampledata --bulk sampledata-file.jsonl + + # or you can simply print it to the stdout + $ mmctl sampledata --bulk - + + # the amount of entities to create can be customized + $ mmctl sampledata -t 7 -u 20 -g 4 + + # the sampledata file can be directly imported in the remote server by not specifying a --bulk flag + $ mmctl sampledata + + # and the sample users can be created with profile pictures + $ mmctl sampledata --profile-images ./images/profiles`, + Args: cobra.NoArgs, + RunE: withClient(sampledataCmdF), +} + +func init() { + SampledataCmd.Flags().Int64P("seed", "s", 1, "Seed used for generating the random data (Different seeds generate different data).") + SampledataCmd.Flags().IntP("teams", "t", 2, "The number of sample teams.") + SampledataCmd.Flags().Int("channels-per-team", 10, "The number of sample channels per team.") + SampledataCmd.Flags().IntP("users", "u", 15, "The number of sample users.") + SampledataCmd.Flags().IntP("guests", "g", 1, "The number of sample guests.") + SampledataCmd.Flags().Int("deactivated-users", 0, "The number of deactivated users.") + SampledataCmd.Flags().Int("team-memberships", 2, "The number of sample team memberships per user.") + SampledataCmd.Flags().Int("channel-memberships", 5, "The number of sample channel memberships per user in a team.") + SampledataCmd.Flags().Int("posts-per-channel", 100, "The number of sample post per channel.") + SampledataCmd.Flags().Int("direct-channels", 30, "The number of sample direct message channels.") + SampledataCmd.Flags().Int("posts-per-direct-channel", 15, "The number of sample posts per direct message channel.") + SampledataCmd.Flags().Int("group-channels", 15, "The number of sample group message channels.") + SampledataCmd.Flags().Int("posts-per-group-channel", 30, "The number of sample posts per group message channel.") + SampledataCmd.Flags().String("profile-images", "", "Optional. Path to folder with images to randomly pick as user profile image.") + SampledataCmd.Flags().StringP("bulk", "b", "", "Optional. Path to write a JSONL bulk file instead of uploading into the remote server.") + + RootCmd.AddCommand(SampledataCmd) +} + +func uploadAndProcess(c client.Client, zipPath string, isLocal bool) error { + zipFile, err := os.Open(zipPath) + if err != nil { + return fmt.Errorf("cannot open import file %q: %w", zipPath, err) + } + defer zipFile.Close() + + info, err := zipFile.Stat() + if err != nil { + return fmt.Errorf("failed to stat import file: %w", err) + } + + userID := "me" + if isLocal { + userID = model.UploadNoUserID + } + + // create session + us, _, err := c.CreateUpload(&model.UploadSession{ + Filename: info.Name(), + FileSize: info.Size(), + Type: model.UploadTypeImport, + UserId: userID, + }) + if err != nil { + return fmt.Errorf("failed to create upload session: %w", err) + } + + printer.PrintT("Upload session successfully created, ID: {{.Id}} ", us) + + // upload file + finfo, _, err := c.UploadData(us.Id, zipFile) + if err != nil { + return fmt.Errorf("failed to upload data: %w", err) + } + + printer.PrintT("Import file successfully uploaded, name: {{.Name}}", finfo) + + // process + job, _, err := c.CreateJob(&model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": us.Id + "_" + finfo.Name, + }, + }) + if err != nil { + return fmt.Errorf("failed to create import process job: %w", err) + } + + printer.PrintT("Import process job successfully created, ID: {{.Id}}", job) + + for { + job, _, err = c.GetJob(job.Id) + if err != nil { + return fmt.Errorf("failed to get import job status: %w", err) + } + + if job.Status != model.JobStatusPending && job.Status != model.JobStatusInProgress { + break + } + + time.Sleep(500 * time.Millisecond) + } + + if job.Status != model.JobStatusSuccess { + return fmt.Errorf("job reported non-success status: %s", job.Status) + } + + printer.PrintT("Sampledata successfully processed", job) + + return nil +} + +func processProfileImagesDir(profileImagesPath, tmpDir, bulk string) ([]string, error) { + profileImages := []string{} + var profileImagesStat os.FileInfo + profileImagesStat, err := os.Stat(profileImagesPath) + if os.IsNotExist(err) { + return nil, fmt.Errorf("profile images folder doesn't exist") + } + if !profileImagesStat.IsDir() { + return nil, fmt.Errorf("profile-images parameters must be a directory") + } + var profileImagesFiles []os.FileInfo + profileImagesFiles, err = ioutil.ReadDir(profileImagesPath) + if err != nil { + return nil, fmt.Errorf("invalid profile-images parameter: %w", err) + } + + // we need to copy the images to be part of the import zip + if bulk == "" { + for _, profileImage := range profileImagesFiles { + profileImageSrc := filepath.Join(profileImagesPath, profileImage.Name()) + profileImagePath := filepath.Join(attachmentsDir, profileImage.Name()) + profileImageDst := filepath.Join(tmpDir, profileImagePath) + if err := pUtils.CopyFile(profileImageSrc, profileImageDst); err != nil { + return nil, fmt.Errorf("cannot copy file %q to %q: %w", profileImageSrc, profileImageDst, err) + } + // the path we use in the profile info is relative to the zipfile base + profileImages = append(profileImages, profileImagePath) + } + // we're not importing the resulting file, so we keep the + // image paths corresponding to the value of the flag + } else { + for _, profileImage := range profileImagesFiles { + profileImages = append(profileImages, filepath.Join(profileImagesPath, profileImage.Name())) + } + } + + sort.Strings(profileImages) + return profileImages, nil +} + +//nolint:gocyclo +func sampledataCmdF(c client.Client, command *cobra.Command, args []string) error { + seed, _ := command.Flags().GetInt64("seed") + bulk, _ := command.Flags().GetString("bulk") + teams, _ := command.Flags().GetInt("teams") + channelsPerTeam, _ := command.Flags().GetInt("channels-per-team") + users, _ := command.Flags().GetInt("users") + deactivatedUsers, _ := command.Flags().GetInt("deactivated-users") + guests, _ := command.Flags().GetInt("guests") + teamMemberships, _ := command.Flags().GetInt("team-memberships") + channelMemberships, _ := command.Flags().GetInt("channel-memberships") + postsPerChannel, _ := command.Flags().GetInt("posts-per-channel") + directChannels, _ := command.Flags().GetInt("direct-channels") + postsPerDirectChannel, _ := command.Flags().GetInt("posts-per-direct-channel") + groupChannels, _ := command.Flags().GetInt("group-channels") + postsPerGroupChannel, _ := command.Flags().GetInt("posts-per-group-channel") + profileImagesPath, _ := command.Flags().GetString("profile-images") + withAttachments := profileImagesPath != "" + + if teamMemberships > teams { + return fmt.Errorf("you can't have more team memberships than teams") + } + if channelMemberships > channelsPerTeam { + return fmt.Errorf("you can't have more channel memberships than channels per team") + } + + if users < 6 && groupChannels > 0 { + return fmt.Errorf("you can't have group channels generation with less than 6 users. Use --group-channels 0 or increase the number of users") + } + + var bulkFile *os.File + var tmpDir string + var err error + switch bulk { + case "": + tmpDir, err = ioutil.TempDir("", "mmctl-sampledata-") + if err != nil { + return fmt.Errorf("unable to create temporary directory") + } + defer os.RemoveAll(tmpDir) + + if withAttachments { + if err = os.Mkdir(filepath.Join(tmpDir, attachmentsDir), 0755); err != nil { + return fmt.Errorf("cannot create attachments directory: %w", err) + } + } + + bulkFile, err = os.Create(filepath.Join(tmpDir, "import.jsonl")) + if err != nil { + return fmt.Errorf("unable to open temporary file: %w", err) + } + defer bulkFile.Close() + case "-": + bulkFile = os.Stdout + default: + bulkFile, err = os.OpenFile(bulk, os.O_RDWR|os.O_CREATE, 0755) + if err != nil { + return fmt.Errorf("unable to write into the %q file: %w", bulk, err) + } + defer bulkFile.Close() + } + + profileImages := []string{} + if profileImagesPath != "" { + profileImages, err = processProfileImagesDir(profileImagesPath, tmpDir, bulk) + if err != nil { + return fmt.Errorf("cannot process profile images directory: %w", err) + } + } + + encoder := json.NewEncoder(bulkFile) + version := 1 + if err := encoder.Encode(imports.LineImportData{Type: "version", Version: &version}); err != nil { + return fmt.Errorf("could not encode version line: %w", err) + } + + fake.Seed(seed) + rand.Seed(seed) + + teamsAndChannels := make(map[string][]string, teams) + for i := 0; i < teams; i++ { + teamLine := createTeam(i) + teamsAndChannels[*teamLine.Team.Name] = []string{} + if err := encoder.Encode(teamLine); err != nil { + return fmt.Errorf("could not encode team line: %w", err) + } + } + + teamsList := make([]string, len(teamsAndChannels)) + teamsListIndex := 0 + for teamName := range teamsAndChannels { + teamsList[teamsListIndex] = teamName + teamsListIndex++ + } + sort.Strings(teamsList) + + for _, teamName := range teamsList { + for i := 0; i < channelsPerTeam; i++ { + channelLine := createChannel(i, teamName) + teamsAndChannels[teamName] = append(teamsAndChannels[teamName], *channelLine.Channel.Name) + if err := encoder.Encode(channelLine); err != nil { + return fmt.Errorf("could not encode channel line: %w", err) + } + } + } + + allUsers := make([]string, users+guests+deactivatedUsers) + allUsersIndex := 0 + for i := 0; i < users; i++ { + userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, "") + if err := encoder.Encode(userLine); err != nil { + return fmt.Errorf("cannot encode user line: %w", err) + } + allUsers[allUsersIndex] = *userLine.User.Username + allUsersIndex++ + } + for i := 0; i < guests; i++ { + userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, guestUser) + if err := encoder.Encode(userLine); err != nil { + return fmt.Errorf("cannot encode user line: %w", err) + } + allUsers[allUsersIndex] = *userLine.User.Username + allUsersIndex++ + } + for i := 0; i < deactivatedUsers; i++ { + userLine := createUser(i, teamMemberships, channelMemberships, teamsAndChannels, profileImages, deactivatedUser) + if err := encoder.Encode(userLine); err != nil { + return fmt.Errorf("cannot encode user line: %w", err) + } + allUsers[allUsersIndex] = *userLine.User.Username + allUsersIndex++ + } + + for team, channels := range teamsAndChannels { + for _, channel := range channels { + dates := sortedRandomDates(postsPerChannel) + + for i := 0; i < postsPerChannel; i++ { + postLine := createPost(team, channel, allUsers, dates[i]) + if err := encoder.Encode(postLine); err != nil { + return fmt.Errorf("cannot encode post line: %w", err) + } + } + } + } + + for i := 0; i < directChannels; i++ { + user1 := allUsers[rand.Intn(len(allUsers))] + user2 := allUsers[rand.Intn(len(allUsers))] + channelLine := createDirectChannel([]string{user1, user2}) + if err := encoder.Encode(channelLine); err != nil { + return fmt.Errorf("cannot encode channel line: %w", err) + } + } + + for i := 0; i < directChannels; i++ { + user1 := allUsers[rand.Intn(len(allUsers))] + user2 := allUsers[rand.Intn(len(allUsers))] + + dates := sortedRandomDates(postsPerDirectChannel) + for j := 0; j < postsPerDirectChannel; j++ { + postLine := createDirectPost([]string{user1, user2}, dates[j]) + if err := encoder.Encode(postLine); err != nil { + return fmt.Errorf("cannot encode post line: %w", err) + } + } + } + + for i := 0; i < groupChannels; i++ { + users := []string{} + totalUsers := 3 + rand.Intn(3) + for len(users) < totalUsers { + user := allUsers[rand.Intn(len(allUsers))] + if !utils.StringInSlice(user, users) { + users = append(users, user) + } + } + channelLine := createDirectChannel(users) + if err := encoder.Encode(channelLine); err != nil { + return fmt.Errorf("cannot encode channel line: %w", err) + } + } + + for i := 0; i < groupChannels; i++ { + users := []string{} + totalUsers := 3 + rand.Intn(3) + for len(users) < totalUsers { + user := allUsers[rand.Intn(len(allUsers))] + if !utils.StringInSlice(user, users) { + users = append(users, user) + } + } + + dates := sortedRandomDates(postsPerGroupChannel) + for j := 0; j < postsPerGroupChannel; j++ { + postLine := createDirectPost(users, dates[j]) + if err := encoder.Encode(postLine); err != nil { + return fmt.Errorf("cannot encode post line: %w", err) + } + } + } + + // if we're writing to stdout, we can finish here + if bulk == "-" { + return nil + } + + if bulk == "" { + zipPath := filepath.Join(os.TempDir(), "mmctl-sampledata.zip") + defer os.Remove(zipPath) + + if err := zipDir(zipPath, tmpDir); err != nil { + return fmt.Errorf("cannot compress %q directory into zipfile: %w", tmpDir, err) + } + + isLocal, _ := command.Flags().GetBool("local") + if err := uploadAndProcess(c, zipPath, isLocal); err != nil { + return fmt.Errorf("cannot upload and process zipfile: %w", err) + } + } + + return nil +} diff --git a/server/cmd/mmctl/commands/sampledata_test.go b/server/cmd/mmctl/commands/sampledata_test.go new file mode 100644 index 0000000000..608a3671c1 --- /dev/null +++ b/server/cmd/mmctl/commands/sampledata_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "os" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestSampledataCmd() { + s.Run("should fail because you have more team memberships than teams", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("teams", 10, "") + cmd.Flags().Int("team-memberships", 11, "") + err := sampledataCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Require().Contains(err.Error(), "more team memberships than teams") + }) + + s.Run("should fail because you have more channel memberships than channels per team", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("channels-per-team", 10, "") + cmd.Flags().Int("channel-memberships", 11, "") + err := sampledataCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Require().Contains(err.Error(), "more channel memberships than channels per team") + }) + + s.Run("should fail because you have group channels and don't have enough users (6 users)", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Int("group-channels", 1, "") + cmd.Flags().Int("users", 5, "") + err := sampledataCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Require().Contains(err.Error(), "group channels generation with less than 6 users") + }) + + s.Run("should not fail with less than 6 users and no group channels", func() { + printer.Clean() + + tmpFile, err := ioutil.TempFile("", "mmctl-sampledata-test-") + s.Require().NoError(err) + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + cmd := &cobra.Command{} + cmd.Flags().String("bulk", tmpFile.Name(), "") + cmd.Flags().Int("group-channels", 0, "") + cmd.Flags().Int("users", 5, "") + err = sampledataCmdF(s.client, cmd, []string{}) + s.Require().NoError(err) + }) +} diff --git a/server/cmd/mmctl/commands/sampledata_util.go b/server/cmd/mmctl/commands/sampledata_util.go new file mode 100644 index 0000000000..7dc2af9511 --- /dev/null +++ b/server/cmd/mmctl/commands/sampledata_util.go @@ -0,0 +1,451 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//nolint:gosec +package commands + +import ( + "fmt" + "math/rand" + "sort" + "strings" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/app" + "github.com/mattermost/mattermost-server/server/v8/channels/app/imports" + + "github.com/icrowley/fake" +) + +func randomPastTime(seconds int) int64 { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.FixedZone("UTC", 0)) + return (today.Unix() * 1000) - int64(rand.Intn(seconds*1000)) +} + +func sortedRandomDates(size int) []int64 { + dates := make([]int64, size) + for i := 0; i < size; i++ { + dates[i] = randomPastTime(50000) + } + sort.Slice(dates, func(a, b int) bool { return dates[a] < dates[b] }) + return dates +} + +func randomEmoji() string { + emojis := []string{"+1", "-1", "heart", "blush"} + return emojis[rand.Intn(len(emojis))] +} + +func randomReaction(users []string, parentCreateAt int64) app.ReactionImportData { + user := users[rand.Intn(len(users))] + emoji := randomEmoji() + date := parentCreateAt + int64(rand.Intn(100000)) + return app.ReactionImportData{ + User: &user, + EmojiName: &emoji, + CreateAt: &date, + } +} + +func randomReply(users []string, parentCreateAt int64) imports.ReplyImportData { + user := users[rand.Intn(len(users))] + message := randomMessage(users) + date := parentCreateAt + int64(rand.Intn(100000)) + return imports.ReplyImportData{ + User: &user, + Message: &message, + CreateAt: &date, + } +} + +func randomMessage(users []string) string { + var message string + switch rand.Intn(30) { + case 0: + mention := users[rand.Intn(len(users))] + message = "@" + mention + " " + fake.Sentence() + case 1: + switch rand.Intn(2) { + case 0: + mattermostVideos := []string{"Q4MgnxbpZas", "BFo7E9-Kc_E", "LsMLR-BHsKg", "MRmGDhlMhNA", "mUOPxT7VgWc"} + message = "https://www.youtube.com/watch?v=" + mattermostVideos[rand.Intn(len(mattermostVideos))] + case 1: + mattermostTweets := []string{"943119062334353408", "949370809528832005", "948539688171819009", "939122439115681792", "938061722027425797"} + message = "https://twitter.com/mattermosthq/status/" + mattermostTweets[rand.Intn(len(mattermostTweets))] + } + case 2: + message = "" + if rand.Intn(2) == 0 { + message += fake.Sentence() + } + for i := 0; i < rand.Intn(4)+1; i++ { + message += "\n * " + fake.Word() + } + default: + if rand.Intn(2) == 0 { + message = fake.Sentence() + } else { + message = fake.Paragraph() + } + if rand.Intn(3) == 0 { + message += "\n" + fake.Sentence() + } + if rand.Intn(3) == 0 { + message += "\n" + fake.Sentence() + } + if rand.Intn(3) == 0 { + message += "\n" + fake.Sentence() + } + } + return message +} + +func createUser(idx int, teamMemberships int, channelMemberships int, teamsAndChannels map[string][]string, profileImages []string, userType string) imports.LineImportData { + firstName := fake.FirstName() + lastName := fake.LastName() + position := fake.JobTitle() + + username := fmt.Sprintf("%s.%s", strings.ToLower(firstName), strings.ToLower(lastName)) + roles := "system_user" + + var password string + var email string + + switch userType { + case guestUser: + password = fmt.Sprintf("SampleGu@st-%d", idx) + email = fmt.Sprintf("guest-%d@sample.mattermost.com", idx) + roles = "system_guest" + if idx == 0 { + username = "guest" + password = "SampleGu@st1" + email = "guest@sample.mattermost.com" + } + case deactivatedUser: + password = fmt.Sprintf("SampleDe@ctivated-%d", idx) + email = fmt.Sprintf("deactivated-%d@sample.mattermost.com", idx) + default: + password = fmt.Sprintf("SampleUs@r-%d", idx) + email = fmt.Sprintf("user-%d@sample.mattermost.com", idx) + if idx == 0 { + username = "sysadmin" + password = "Sys@dmin-sample1" + email = "sysadmin@sample.mattermost.com" + } else if idx == 1 { + username = "user-1" + } + + if idx%5 == 0 { + roles = "system_admin system_user" + } + } + + // The 75% of the users have custom profile image + var profileImage *string + if rand.Intn(4) != 0 { + profileImageSelector := rand.Int() + if len(profileImages) > 0 { + profileImage = &profileImages[profileImageSelector%len(profileImages)] + } + } + + useMilitaryTime := "false" + if idx != 0 && rand.Intn(2) == 0 { + useMilitaryTime = "true" + } + + collapsePreviews := "false" + if idx != 0 && rand.Intn(2) == 0 { + collapsePreviews = "true" + } + + messageDisplay := "clean" + if idx != 0 && rand.Intn(2) == 0 { + messageDisplay = "compact" + } + + channelDisplayMode := "full" + if idx != 0 && rand.Intn(2) == 0 { + channelDisplayMode = "centered" + } + + // Some users has nickname + nickname := "" + if rand.Intn(5) == 0 { + nickname = fake.Company() + } + + // sysadmin, user-1 and user-2 users skip tutorial steps + // Other half of users also skip tutorial steps + tutorialStep := "999" + if idx > 2 { + switch rand.Intn(6) { + case 1: + tutorialStep = "1" + case 2: + tutorialStep = "2" + case 3: + tutorialStep = "3" + } + } + + teams := []imports.UserTeamImportData{} + possibleTeams := []string{} + for teamName := range teamsAndChannels { + possibleTeams = append(possibleTeams, teamName) + } + sort.Strings(possibleTeams) + for x := 0; x < teamMemberships; x++ { + if len(possibleTeams) == 0 { + break + } + position := rand.Intn(len(possibleTeams)) + team := possibleTeams[position] + possibleTeams = append(possibleTeams[:position], possibleTeams[position+1:]...) + if teamChannels, err := teamsAndChannels[team]; err { + teams = append(teams, createTeamMembership(channelMemberships, teamChannels, &team, userType == guestUser)) + } + } + + var deleteAt int64 + if userType == deactivatedUser { + deleteAt = model.GetMillis() + } + + user := imports.UserImportData{ + ProfileImage: profileImage, + Username: &username, + Email: &email, + Password: &password, + Nickname: &nickname, + FirstName: &firstName, + LastName: &lastName, + Position: &position, + Roles: &roles, + Teams: &teams, + UseMilitaryTime: &useMilitaryTime, + CollapsePreviews: &collapsePreviews, + MessageDisplay: &messageDisplay, + ChannelDisplayMode: &channelDisplayMode, + TutorialStep: &tutorialStep, + DeleteAt: &deleteAt, + } + return imports.LineImportData{ + Type: "user", + User: &user, + } +} + +func createTeamMembership(numOfchannels int, teamChannels []string, teamName *string, guest bool) imports.UserTeamImportData { + roles := "team_user" + if guest { + roles = "team_guest" + } else if rand.Intn(5) == 0 { + roles = "team_user team_admin" + } + channels := []imports.UserChannelImportData{} + teamChannelsCopy := append([]string(nil), teamChannels...) + for x := 0; x < numOfchannels; x++ { + if len(teamChannelsCopy) == 0 { + break + } + position := rand.Intn(len(teamChannelsCopy)) + channelName := teamChannelsCopy[position] + teamChannelsCopy = append(teamChannelsCopy[:position], teamChannelsCopy[position+1:]...) + channels = append(channels, createChannelMembership(channelName, guest)) + } + + return imports.UserTeamImportData{ + Name: teamName, + Roles: &roles, + Channels: &channels, + } +} + +func createChannelMembership(channelName string, guest bool) imports.UserChannelImportData { + roles := "channel_user" + if guest { + roles = "channel_guest" + } else if rand.Intn(5) == 0 { + roles = "channel_user channel_admin" + } + favorite := rand.Intn(5) == 0 + + return imports.UserChannelImportData{ + Name: &channelName, + Roles: &roles, + Favorite: &favorite, + } +} + +func getSampleTeamName(idx int) string { + for { + name := fmt.Sprintf("%s-%d", fake.Word(), idx) + if !model.IsReservedTeamName(name) { + return name + } + } +} + +func createTeam(idx int) imports.LineImportData { + displayName := fake.Word() + name := getSampleTeamName(idx) + allowOpenInvite := rand.Intn(2) == 0 + + description := fake.Paragraph() + if len(description) > 255 { + description = description[0:255] + } + + teamType := "O" + if rand.Intn(2) == 0 { + teamType = "I" + } + + team := imports.TeamImportData{ + DisplayName: &displayName, + Name: &name, + AllowOpenInvite: &allowOpenInvite, + Description: &description, + Type: &teamType, + } + return imports.LineImportData{ + Type: "team", + Team: &team, + } +} + +func createChannel(idx int, teamName string) imports.LineImportData { + displayName := fake.Word() + name := fmt.Sprintf("%s-%d", fake.Word(), idx) + header := fake.Paragraph() + purpose := fake.Paragraph() + + if len(purpose) > 250 { + purpose = purpose[0:250] + } + + channelType := model.ChannelTypePrivate + if rand.Intn(2) == 0 { + channelType = model.ChannelTypeOpen + } + + channel := imports.ChannelImportData{ + Team: &teamName, + Name: &name, + DisplayName: &displayName, + Type: &channelType, + Header: &header, + Purpose: &purpose, + } + return imports.LineImportData{ + Type: "channel", + Channel: &channel, + } +} + +func createPost(team string, channel string, allUsers []string, createAt int64) imports.LineImportData { + message := randomMessage(allUsers) + user := allUsers[rand.Intn(len(allUsers))] + + // Some messages are flagged by a user + flaggedBy := []string{} + if rand.Intn(10) == 0 { + flaggedBy = append(flaggedBy, allUsers[rand.Intn(len(allUsers))]) + } + + reactions := []app.ReactionImportData{} + if rand.Intn(10) == 0 { + for { + reactions = append(reactions, randomReaction(allUsers, createAt)) + if rand.Intn(3) == 0 { + break + } + } + } + + replies := []imports.ReplyImportData{} + if rand.Intn(10) == 0 { + for { + replies = append(replies, randomReply(allUsers, createAt)) + if rand.Intn(4) == 0 { + break + } + } + } + + post := imports.PostImportData{ + Team: &team, + Channel: &channel, + User: &user, + Message: &message, + CreateAt: &createAt, + FlaggedBy: &flaggedBy, + Reactions: &reactions, + Replies: &replies, + } + return imports.LineImportData{ + Type: "post", + Post: &post, + } +} + +func createDirectChannel(members []string) imports.LineImportData { + header := fake.Sentence() + + channel := imports.DirectChannelImportData{ + Members: &members, + Header: &header, + } + return imports.LineImportData{ + Type: "direct_channel", + DirectChannel: &channel, + } +} + +func createDirectPost(members []string, createAt int64) imports.LineImportData { + message := randomMessage(members) + user := members[rand.Intn(len(members))] + + // Some messages are flagged by an user + flaggedBy := []string{} + if rand.Intn(10) == 0 { + flaggedBy = append(flaggedBy, members[rand.Intn(len(members))]) + } + + reactions := []app.ReactionImportData{} + if rand.Intn(10) == 0 { + for { + reactions = append(reactions, randomReaction(members, createAt)) + if rand.Intn(3) == 0 { + break + } + } + } + + replies := []imports.ReplyImportData{} + if rand.Intn(10) == 0 { + for { + replies = append(replies, randomReply(members, createAt)) + if rand.Intn(4) == 0 { + break + } + } + } + + post := imports.DirectPostImportData{ + ChannelMembers: &members, + User: &user, + Message: &message, + CreateAt: &createAt, + FlaggedBy: &flaggedBy, + Reactions: &reactions, + Replies: &replies, + } + return imports.LineImportData{ + Type: "direct_post", + DirectPost: &post, + } +} diff --git a/server/cmd/mmctl/commands/system.go b/server/cmd/mmctl/commands/system.go new file mode 100644 index 0000000000..028741f5ba --- /dev/null +++ b/server/cmd/mmctl/commands/system.go @@ -0,0 +1,153 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +var SystemCmd = &cobra.Command{ + Use: "system", + Short: "System management", + Long: `System management commands for interacting with the server state and configuration.`, +} + +var SystemGetBusyCmd = &cobra.Command{ + Use: "getbusy", + Short: "Get the current busy state", + Long: `Gets the server busy state (high load) and timestamp corresponding to when the server busy flag will be automatically cleared.`, + Example: ` system getbusy`, + Args: cobra.NoArgs, + RunE: withClient(getBusyCmdF), +} + +var SystemSetBusyCmd = &cobra.Command{ + Use: "setbusy -s [seconds]", + Short: "Set the busy state to true", + Long: `Set the busy state to true for the specified number of seconds, which disables non-critical services.`, + Example: ` system setbusy -s 3600`, + Args: cobra.NoArgs, + RunE: withClient(setBusyCmdF), +} + +var SystemClearBusyCmd = &cobra.Command{ + Use: "clearbusy", + Short: "Clears the busy state", + Long: `Clear the busy state, which re-enables non-critical services.`, + Example: ` system clearbusy`, + Args: cobra.NoArgs, + RunE: withClient(clearBusyCmdF), +} + +var SystemVersionCmd = &cobra.Command{ + Use: "version", + Short: "Prints the remote server version", + Long: "Prints the server version of the currently connected Mattermost instance", + Example: ` system version`, + Args: cobra.NoArgs, + RunE: withClient(systemVersionCmdF), +} + +var SystemStatusCmd = &cobra.Command{ + Use: "status", + Short: "Prints the status of the server", + Long: "Prints the server status calculated using several basic server healthchecks", + Example: ` system status`, + Args: cobra.NoArgs, + RunE: withClient(systemStatusCmdF), +} + +func init() { + SystemSetBusyCmd.Flags().UintP("seconds", "s", 3600, "Number of seconds until server is automatically marked as not busy.") + _ = SystemSetBusyCmd.MarkFlagRequired("seconds") + SystemCmd.AddCommand( + SystemGetBusyCmd, + SystemSetBusyCmd, + SystemClearBusyCmd, + SystemVersionCmd, + SystemStatusCmd, + ) + RootCmd.AddCommand(SystemCmd) +} + +func getBusyCmdF(c client.Client, cmd *cobra.Command, _ []string) error { + printer.SetSingle(true) + + sbs, _, err := c.GetServerBusy() + if err != nil { + return fmt.Errorf("unable to get busy state: %w", err) + } + printer.PrintT("busy:{{.Busy}} expires:{{.Expires_ts}}", sbs) + return nil +} + +func setBusyCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + seconds, err := cmd.Flags().GetUint("seconds") + if err != nil || seconds == 0 { + return errors.New("seconds must be a number > 0") + } + + _, err = c.SetServerBusy(int(seconds)) + if err != nil { + return fmt.Errorf("unable to set busy state: %w", err) + } + + printer.PrintT("Busy state set", map[string]string{"status": "ok"}) + return nil +} + +func clearBusyCmdF(c client.Client, cmd *cobra.Command, _ []string) error { + printer.SetSingle(true) + + _, err := c.ClearServerBusy() + if err != nil { + return fmt.Errorf("unable to clear busy state: %w", err) + } + printer.PrintT("Busy state cleared", map[string]string{"status": "ok"}) + return nil +} + +func systemVersionCmdF(c client.Client, cmd *cobra.Command, _ []string) error { + printer.SetSingle(true) + // server version information comes with all responses. We can't + // use the initial "withClient" connection information as local + // mode doesn't need to log in, so we use an endpoint that will + // always return a valid response + _, resp, err := c.GetPing() + if err != nil { + return fmt.Errorf("unable to fetch server version: %w", err) + } + + printer.PrintT("Server version {{.version}}", map[string]string{"version": resp.ServerVersion}) + return nil +} + +func systemStatusCmdF(c client.Client, cmd *cobra.Command, _ []string) error { + printer.SetSingle(true) + + status, _, err := c.GetPingWithFullServerStatus() + if err != nil { + return fmt.Errorf("unable to fetch server status: %w", err) + } + + printer.PrintT(`Server status: {{.status}} +Android Latest Version: {{.AndroidLatestVersion}} +Android Minimum Version: {{.AndroidMinVersion}} +Desktop Latest Version: {{.DesktopLatestVersion}} +Desktop Minimum Version: {{.DesktopMinVersion}} +Ios Latest Version: {{.IosLatestVersion}} +Ios Minimum Version: {{.IosMinVersion}} +Database Status: {{.database_status}} +Filestore Status: {{.filestore_status}}`, status) + + return nil +} diff --git a/server/cmd/mmctl/commands/system_e2e_test.go b/server/cmd/mmctl/commands/system_e2e_test.go new file mode 100644 index 0000000000..438196b64a --- /dev/null +++ b/server/cmd/mmctl/commands/system_e2e_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "time" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" +) + +func (s *MmctlE2ETestSuite) TestGetBusyCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + s.th.App.Srv().Platform().Busy.Set(time.Minute) + defer s.th.App.Srv().Platform().Busy.Clear() + + s.Run("MM-T3979 Should fail when regular user attempts to get server busy status", func() { + printer.Clean() + + err := getBusyCmdF(s.th.Client, &cobra.Command{}, nil) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.RunForSystemAdminAndLocal("MM-T3956 Get server busy status", func(c client.Client) { + printer.Clean() + + err := getBusyCmdF(c, &cobra.Command{}, nil) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + state, ok := printer.GetLines()[0].(*model.ServerBusyState) + s.Require().True(ok, true) + s.Require().True(state.Busy, true) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestSetBusyCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + s.th.App.Srv().Platform().Busy.Clear() + cmd := &cobra.Command{} + cmd.Flags().Uint("seconds", 60, "") + + s.Run("MM-T3980 Should fail when regular user attempts to set server busy status", func() { + printer.Clean() + + err := setBusyCmdF(s.th.Client, cmd, nil) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.RunForSystemAdminAndLocal("MM-T3957 Set server status to busy", func(c client.Client) { + printer.Clean() + + err := setBusyCmdF(c, cmd, nil) + s.Require().NoError(err) + defer func() { + s.th.App.Srv().Platform().Busy.Clear() + s.Require().False(s.th.App.Srv().Platform().Busy.IsBusy()) + }() + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], map[string]string{"status": "ok"}) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().True(s.th.App.Srv().Platform().Busy.IsBusy()) + }) +} + +func (s *MmctlE2ETestSuite) TestClearBusyCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + s.th.App.Srv().Platform().Busy.Set(time.Minute) + defer s.th.App.Srv().Platform().Busy.Clear() + + s.Run("MM-T3981 Should fail when regular user attempts to clear server busy status", func() { + printer.Clean() + + err := clearBusyCmdF(s.th.Client, &cobra.Command{}, nil) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.RunForSystemAdminAndLocal("MM-T3958 Clear server status to busy", func(c client.Client) { + printer.Clean() + + err := clearBusyCmdF(c, &cobra.Command{}, nil) + s.Require().NoError(err) + defer func() { + s.th.App.Srv().Platform().Busy.Set(time.Minute) + s.Require().True(s.th.App.Srv().Platform().Busy.IsBusy()) + }() + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], map[string]string{"status": "ok"}) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().False(s.th.App.Srv().Platform().Busy.IsBusy()) + }) +} diff --git a/server/cmd/mmctl/commands/system_test.go b/server/cmd/mmctl/commands/system_test.go new file mode 100644 index 0000000000..7c8efdc58f --- /dev/null +++ b/server/cmd/mmctl/commands/system_test.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + "strconv" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestGetBusyCmd() { + s.Run("GetBusy when not set", func() { + printer.Clean() + sbs := &model.ServerBusyState{} + + s.client. + EXPECT(). + GetServerBusy(). + Return(sbs, &model.Response{}, nil). + Times(1) + + err := getBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(sbs, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("GetBusy when set", func() { + printer.Clean() + const minutes = 15 + expires := time.Now().Add(time.Minute * minutes).Unix() + sbs := &model.ServerBusyState{Busy: true, Expires: expires} + + s.client. + EXPECT(). + GetServerBusy(). + Return(sbs, &model.Response{}, nil). + Times(1) + + err := getBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(sbs, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("GetBusy with error", func() { + printer.Clean() + s.client. + EXPECT(). + GetServerBusy(). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + err := getBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestSetBusyCmd() { + s.Run("SetBusy 900 seconds", func() { + printer.Clean() + const minutes = 15 + + cmd := &cobra.Command{} + cmd.Flags().Uint("seconds", minutes*60, "") + + s.client. + EXPECT(). + SetServerBusy(minutes*60). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := setBusyCmdF(s.client, cmd, []string{strconv.Itoa(minutes * 60)}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(map[string]string{"status": "ok"}, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("SetBusy with missing arg", func() { + printer.Clean() + + err := setBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("SetBusy zero seconds", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Uint("seconds", 0, "") + + err := setBusyCmdF(s.client, cmd, []string{strconv.Itoa(0)}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestClearBusyCmd() { + s.Run("ClearBusy", func() { + printer.Clean() + s.client. + EXPECT(). + ClearServerBusy(). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := clearBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(map[string]string{"status": "ok"}, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("ClearBusy with error", func() { + printer.Clean() + s.client. + EXPECT(). + ClearServerBusy(). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := clearBusyCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestServerVersionCmd() { + s.Run("Print server version", func() { + printer.Clean() + + expectedVersion := "1.23.4.dev" + s.client. + EXPECT(). + GetPing(). + Return("", &model.Response{ServerVersion: expectedVersion}, nil). + Times(1) + + err := systemVersionCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], map[string]string{"version": expectedVersion}) + }) + + s.Run("Request to the server fails", func() { + printer.Clean() + + s.client. + EXPECT(). + GetPing(). + Return("", &model.Response{}, errors.New("mock error")). + Times(1) + + err := systemVersionCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestServerStatusCmd() { + s.Run("Print server status", func() { + printer.Clean() + + expectedStatus := map[string]string{"status": "OK"} + s.client. + EXPECT(). + GetPingWithFullServerStatus(). + Return(expectedStatus, &model.Response{}, nil). + Times(1) + + err := systemStatusCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], expectedStatus) + }) + + s.Run("Request to the server fails", func() { + printer.Clean() + + s.client. + EXPECT(). + GetPingWithFullServerStatus(). + Return(nil, &model.Response{}, errors.New("mock error")). + Times(1) + + err := systemStatusCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/team.go b/server/cmd/mmctl/commands/team.go new file mode 100644 index 0000000000..8495819b8d --- /dev/null +++ b/server/cmd/mmctl/commands/team.go @@ -0,0 +1,378 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "sort" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +const APILimitMaximum = 200 + +var TeamCmd = &cobra.Command{ + Use: "team", + Short: "Management of teams", +} + +var TeamCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a team", + Long: `Create a team.`, + Example: ` team create --name mynewteam --display-name "My New Team" + team create --name private --display-name "My New Private Team" --private`, + RunE: withClient(createTeamCmdF), +} + +var DeleteTeamsCmd = &cobra.Command{ + Use: "delete [teams]", + Short: "Delete teams", + Long: `Permanently delete some teams. +Permanently deletes a team along with all related information including posts from the database.`, + Example: " team delete myteam", + Args: cobra.MinimumNArgs(1), + RunE: withClient(deleteTeamsCmdF), +} + +var ArchiveTeamsCmd = &cobra.Command{ + Use: "archive [teams]", + Short: "Archive teams", + Long: `Archive some teams. +Archives a team along with all related information including posts from the database.`, + Example: " team archive myteam", + Args: cobra.MinimumNArgs(1), + RunE: withClient(archiveTeamsCmdF), +} + +var RestoreTeamsCmd = &cobra.Command{ + Use: "restore [teams]", + Short: "Restore teams", + Long: "Restores archived teams.", + Example: " team restore myteam", + Args: cobra.MinimumNArgs(1), + RunE: withClient(restoreTeamsCmdF), +} + +var ListTeamsCmd = &cobra.Command{ + Use: "list", + Short: "List all teams", + Long: `List all teams on the server.`, + Example: " team list", + RunE: withClient(listTeamsCmdF), +} + +var SearchTeamCmd = &cobra.Command{ + Use: "search [teams]", + Short: "Search for teams", + Long: "Search for teams based on name", + Example: " team search team1", + Args: cobra.MinimumNArgs(1), + RunE: withClient(searchTeamCmdF), +} + +// RenameTeamCmd is the command to rename team along with its display name +var RenameTeamCmd = &cobra.Command{ + Use: "rename [team]", + Short: "Rename team", + Long: "Rename an existing team", + Example: " team rename old-team --display-name 'New Display Name'", + Args: cobra.ExactArgs(1), + RunE: withClient(renameTeamCmdF), +} + +var ModifyTeamsCmd = &cobra.Command{ + Use: "modify [teams] [flag]", + Short: "Modify teams", + Long: "Modify teams' privacy setting to public or private", + Example: " team modify myteam --private", + Args: cobra.MinimumNArgs(1), + RunE: withClient(modifyTeamsCmdF), +} + +func init() { + TeamCreateCmd.Flags().String("name", "", "Team Name") + TeamCreateCmd.Flags().String("display-name", "", "Team Display Name") + TeamCreateCmd.Flags().String("display_name", "", "") + _ = TeamCreateCmd.Flags().MarkDeprecated("display_name", "please use display-name instead") + TeamCreateCmd.Flags().Bool("private", false, "Create a private team.") + TeamCreateCmd.Flags().String("email", "", "Administrator Email (anyone with this email is automatically a team admin)") + + DeleteTeamsCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the team and a DB backup has been performed.") + ArchiveTeamsCmd.Flags().Bool("confirm", false, "Confirm you really want to archive the team and a DB backup has been performed.") + + ModifyTeamsCmd.Flags().Bool("private", false, "Modify team to be private.") + ModifyTeamsCmd.Flags().Bool("public", false, "Modify team to be public.") + + // Add flag declaration for RenameTeam + RenameTeamCmd.Flags().String("display-name", "", "Team Display Name") + // _ = RenameTeamCmd.MarkFlagRequired("display-name") // Uncomment this after fully deprecation of display_name + RenameTeamCmd.Flags().String("display_name", "", "") + _ = RenameTeamCmd.Flags().MarkDeprecated("display_name", "please use display-name instead") + + TeamCmd.AddCommand( + TeamCreateCmd, + DeleteTeamsCmd, + ArchiveTeamsCmd, + RestoreTeamsCmd, + ListTeamsCmd, + SearchTeamCmd, + RenameTeamCmd, + ModifyTeamsCmd, + ) + + RootCmd.AddCommand(TeamCmd) +} + +func createTeamCmdF(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 == "" { + displayname, errdn = cmd.Flags().GetString("display_name") + if errdn != nil || displayname == "" { + return errors.New("display Name is required") + } + } + email, _ := cmd.Flags().GetString("email") + useprivate, _ := cmd.Flags().GetBool("private") + + teamType := model.TeamOpen + allowOpenInvite := true + if useprivate { + teamType = model.TeamInvite + allowOpenInvite = false + } + + team := &model.Team{ + Name: name, + DisplayName: displayname, + Email: email, + Type: teamType, + AllowOpenInvite: allowOpenInvite, + } + + newTeam, _, err := c.CreateTeam(team) + if err != nil { + return errors.New("Team creation failed: " + err.Error()) + } + + printer.PrintT("New team {{.Name}} successfully created", newTeam) + + return nil +} + +func deleteTeam(c client.Client, team *model.Team) (*model.Response, error) { + return c.PermanentDeleteTeam(team.Id) +} + +func archiveTeamsCmdF(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 archive the specified teams?", true); err != nil { + return err + } + } + + teams := getTeamsFromTeamArgs(c, args) + for i, team := range teams { + if team == nil { + printer.PrintError("Unable to find team '" + args[i] + "'") + continue + } + if _, err := c.SoftDeleteTeam(team.Id); err != nil { + printer.PrintError("Unable to archive team '" + team.Name + "' error: " + err.Error()) + } else { + printer.PrintT("Archived team '{{.Name}}'", team) + } + } + + return nil +} + +func listTeamsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + page := 0 + for { + teams, _, err := c.GetAllTeams("", page, APILimitMaximum) + if err != nil { + return err + } + + for _, team := range teams { + if team.DeleteAt > 0 { + printer.PrintT("{{.Name}} (archived)", team) + } else { + printer.PrintT("{{.Name}}", team) + } + } + + if len(teams) < APILimitMaximum { + break + } + + page++ + } + + return nil +} + +func searchTeamCmdF(c client.Client, cmd *cobra.Command, args []string) error { + var teams []*model.Team + + for _, searchTerm := range args { + foundTeams, _, err := c.SearchTeams(&model.TeamSearch{Term: searchTerm}) + if err != nil { + return err + } + + if len(foundTeams) == 0 { + printer.PrintError("Unable to find team '" + searchTerm + "'") + continue + } + + teams = append(teams, foundTeams...) + } + + sortedTeams := removeDuplicatesAndSortTeams(teams) + + for _, team := range sortedTeams { + printer.PrintT("{{.Name}}: {{.DisplayName}} ({{.Id}})", team) + } + + return nil +} + +// Removes duplicates and sorts teams by name +func removeDuplicatesAndSortTeams(teams []*model.Team) []*model.Team { + keys := make(map[string]bool) + result := []*model.Team{} + for _, team := range teams { + if _, value := keys[team.Name]; !value { + keys[team.Name] = true + result = append(result, team) + } + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +func renameTeamCmdF(c client.Client, cmd *cobra.Command, args []string) error { + oldTeamName := args[0] + + newDisplayName, _ := cmd.Flags().GetString("display_name") + + if newDisplayName == "" { + newDisplayName, _ = cmd.Flags().GetString("display-name") + } + if newDisplayName == "" { + return errors.New("display name is required") + } + + team := getTeamFromTeamArg(c, oldTeamName) + if team == nil { + return errors.New("Unable to find team '" + oldTeamName + "', to see the all teams try 'team list' command") + } + + team.DisplayName = newDisplayName + + // Using UpdateTeam API Method to rename team + _, _, err := c.UpdateTeam(team) + if err != nil { + return errors.New("Cannot rename team '" + oldTeamName + "', error : " + err.Error()) + } + + printer.Print("'" + oldTeamName + "' team renamed") + return nil +} + +func deleteTeamsCmdF(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 teams specified? All data will be permanently deleted?", true); err != nil { + return err + } + } + + var result *multierror.Error + + teams := getTeamsFromTeamArgs(c, args) + for i, team := range teams { + if team == nil { + printer.PrintError("Unable to find team '" + args[i] + "'") + result = multierror.Append(result, fmt.Errorf("unable to find team %s", args[i])) + continue + } + if _, err := deleteTeam(c, team); err != nil { + printer.PrintError("Unable to delete team '" + team.Name + "' error: " + err.Error()) + result = multierror.Append(result, fmt.Errorf("unable to delete team %s error: %w", team.Name, err)) + } else { + printer.PrintT("Deleted team '{{.Name}}'", team) + } + } + + return result.ErrorOrNil() +} + +func modifyTeamsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + private, _ := cmd.Flags().GetBool("private") + public, _ := cmd.Flags().GetBool("public") + + if (!private && !public) || (private && public) { + return errors.New("must specify one of --private or --public") + } + + // I = invite only (private) + // O = open (public) + privacy := model.TeamInvite + if public { + privacy = model.TeamOpen + } + + teams := getTeamsFromTeamArgs(c, args) + for i, team := range teams { + if team == nil { + printer.PrintError("Unable to find team '" + args[i] + "'") + continue + } + if updatedTeam, _, err := c.UpdateTeamPrivacy(team.Id, privacy); err != nil { + printer.PrintError("Unable to modify team '" + team.Name + "' error: " + err.Error()) + } else { + printer.PrintT("Modified team '{{.Name}}'", updatedTeam) + } + } + + return nil +} + +func restoreTeamsCmdF(c client.Client, cmd *cobra.Command, args []string) error { + teams := getTeamsFromTeamArgs(c, args) + var result *multierror.Error + for i, team := range teams { + if team == nil { + result = multierror.Append(result, fmt.Errorf("unable to find team '%s'", args[i])) + printer.PrintError("Unable to find team '" + args[i] + "'") + continue + } + if rteam, _, err := c.RestoreTeam(team.Id); err != nil { + result = multierror.Append(result, fmt.Errorf("unable to restore team '%s' error: %w", team.Name, err)) + printer.PrintError("Unable to restore team '" + team.Name + "' error: " + err.Error()) + } else { + printer.PrintT("Restored team '{{.Name}}'", rteam) + } + } + return result.ErrorOrNil() +} diff --git a/server/cmd/mmctl/commands/team_e2e_test.go b/server/cmd/mmctl/commands/team_e2e_test.go new file mode 100644 index 0000000000..29b421b6d0 --- /dev/null +++ b/server/cmd/mmctl/commands/team_e2e_test.go @@ -0,0 +1,479 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestRenameTeamCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Error renaming team which does not exist", func(c client.Client) { + printer.Clean() + nonExistentTeamName := "existingName" + cmd := &cobra.Command{} + args := []string{nonExistentTeamName} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + + err := renameTeamCmdF(c, cmd, args) + s.Require().EqualError(err, "Unable to find team 'existingName', to see the all teams try 'team list' command") + }) + + s.RunForSystemAdminAndLocal("Rename an existing team", func(c client.Client) { + printer.Clean() + + cmd := &cobra.Command{} + args := []string{s.th.BasicTeam.Name} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + + err := renameTeamCmdF(c, cmd, args) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Equal("'"+s.th.BasicTeam.Name+"' team renamed", printer.GetLines()[0]) + s.Len(printer.GetErrorLines(), 0) + }) + + s.Run("Permission error renaming an existing team", func() { + printer.Clean() + + cmd := &cobra.Command{} + args := []string{s.th.BasicTeam.Name} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + + err := renameTeamCmdF(s.th.Client, cmd, args) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.ErrorContains(err, "Cannot rename team '"+s.th.BasicTeam.Name+"', error : : You do not have the appropriate permissions.") + }) +} + +func (s *MmctlE2ETestSuite) TestDeleteTeamsCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Error deleting team which does not exist", func(c client.Client) { + printer.Clean() + nonExistentName := "existingName" + cmd := &cobra.Command{} + args := []string{nonExistentName} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + cmd.Flags().Bool("confirm", true, "") + + _ = deleteTeamsCmdF(c, cmd, args) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to find team '"+nonExistentName+"'", printer.GetErrorLines()[0]) + }) + + s.Run("Permission error while deleting a valid team", func() { + printer.Clean() + + cmd := &cobra.Command{} + args := []string{s.th.BasicTeam.Name} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + cmd.Flags().Bool("confirm", true, "") + + _ = deleteTeamsCmdF(s.th.Client, cmd, args) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to delete team '"+s.th.BasicTeam.Name+"' error: : You do not have the appropriate permissions.", printer.GetErrorLines()[0]) + team, _ := s.th.App.GetTeam(s.th.BasicTeam.Id) + s.Equal(team.Name, s.th.BasicTeam.Name) + }) + + s.RunForSystemAdminAndLocal("Delete a valid team", func(c client.Client) { + printer.Clean() + + teamName := "teamname" + model.NewRandomString(10) + teamDisplayname := "Mock Display Name" + cmd := &cobra.Command{} + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", teamDisplayname, "") + err := createTeamCmdF(s.th.LocalClient, cmd, []string{}) + s.Require().Nil(err) + + cmd = &cobra.Command{} + args := []string{teamName} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + cmd.Flags().Bool("confirm", true, "") + + // Set EnableAPITeamDeletion + enableConfig := true + config, _, _ := c.GetConfig() + config.ServiceSettings.EnableAPITeamDeletion = &enableConfig + _, _, _ = c.UpdateConfig(config) + + // Deletion should succeed for both local and SystemAdmin client now + err = deleteTeamsCmdF(c, cmd, args) + s.Require().Nil(err) + team := printer.GetLines()[0].(*model.Team) + s.Equal(teamName, team.Name) + s.Len(printer.GetErrorLines(), 0) + + // Reset config + enableConfig = false + config, _, _ = c.GetConfig() + config.ServiceSettings.EnableAPITeamDeletion = &enableConfig + _, _, _ = c.UpdateConfig(config) + }) + + s.Run("Permission denied error for system admin when deleting a valid team", func() { + printer.Clean() + + args := []string{s.th.BasicTeam.Name} + cmd := &cobra.Command{} + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + cmd.Flags().Bool("confirm", true, "") + + // Delete should fail for SystemAdmin client + err := deleteTeamsCmdF(s.th.SystemAdminClient, cmd, args) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to delete team '"+s.th.BasicTeam.Name+"' error: : Permanent team deletion feature is not enabled. Please contact your System Administrator.", printer.GetErrorLines()[0]) + + // verify team still exists + team, _ := s.th.App.GetTeam(s.th.BasicTeam.Id) + s.Equal(team.Name, s.th.BasicTeam.Name) + + // Delete should succeed for local client + printer.Clean() + err = deleteTeamsCmdF(s.th.LocalClient, cmd, args) + s.Require().Nil(err) + team = printer.GetLines()[0].(*model.Team) + s.Equal(team.Name, s.th.BasicTeam.Name) + s.Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestModifyTeamsCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("system & local accounts can set a team to private", func(c client.Client) { + printer.Clean() + teamID := s.th.BasicTeam.Id + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + err := modifyTeamsCmdF(c, cmd, []string{teamID}) + s.Require().NoError(err) + + s.Require().Equal(model.TeamInvite, printer.GetLines()[0].(*model.Team).Type) + // teardown + appErr := s.th.App.UpdateTeamPrivacy(teamID, model.TeamOpen, true) + s.Require().Nil(appErr) + t, err := s.th.App.GetTeam(teamID) + s.Require().Nil(err) + s.Require().Equal(model.TeamOpen, t.Type) + }) + + s.Run("user that creates the team can't set team's privacy due to permissions", func() { + printer.Clean() + teamID := s.th.BasicTeam.Id + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + err := modifyTeamsCmdF(s.th.Client, cmd, []string{teamID}) + s.Require().NoError(err) + s.Require().Contains( + printer.GetErrorLines()[0], + fmt.Sprintf("Unable to modify team '%s' error: : You do not have the appropriate permissions.", s.th.BasicTeam.Name), + ) + t, appErr := s.th.App.GetTeam(teamID) + s.Require().Nil(appErr) + s.Require().Equal(model.TeamOpen, t.Type) + }) + + s.Run("basic user with normal permissions that hasn't created the team can't set team's privacy", func() { + printer.Clean() + teamID := s.th.BasicTeam.Id + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + s.th.LoginBasic2() + err := modifyTeamsCmdF(s.th.Client, cmd, []string{teamID}) + s.Require().NoError(err) + s.Require().Contains( + printer.GetErrorLines()[0], + fmt.Sprintf("Unable to modify team '%s' error: : You do not have the appropriate permissions.", s.th.BasicTeam.Name), + ) + t, appErr := s.th.App.GetTeam(teamID) + s.Require().Nil(appErr) + s.Require().Equal(model.TeamOpen, t.Type) + }) +} + +func (s *MmctlE2ETestSuite) TestTeamCreateCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Should not create a team w/o name", func(c client.Client) { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("display-name", "somedisplayname", "") + + err := createTeamCmdF(c, cmd, []string{}) + s.EqualError(err, "name is required") + s.Require().Empty(printer.GetLines()) + }) + + s.RunForAllClients("Should not create a team w/o display-name", func(c client.Client) { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("name", model.NewId(), "") + + err := createTeamCmdF(c, cmd, []string{}) + s.EqualError(err, "display Name is required") + s.Require().Empty(printer.GetLines()) + }) + + s.Run("Should create a new team w/ email using LocalClient", func() { + printer.Clean() + cmd := &cobra.Command{} + teamName := model.NewId() + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", "somedisplayname", "") + email := "someemail@example.com" + cmd.Flags().String("email", email, "") + + err := createTeamCmdF(s.th.LocalClient, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + newTeam, err := s.th.App.GetTeamByName(teamName) + s.Require().Nil(err) + s.Equal(email, newTeam.Email) + }) + + s.Run("Should create a new team w/ assigned email using SystemAdminClient", func() { + printer.Clean() + cmd := &cobra.Command{} + teamName := model.NewId() + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", "somedisplayname", "") + email := "someemail@example.com" + cmd.Flags().String("email", email, "") + + err := createTeamCmdF(s.th.SystemAdminClient, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + newTeam, err := s.th.App.GetTeamByName(teamName) + s.Require().Nil(err) + s.NotEqual(email, newTeam.Email) + }) + + s.Run("Should create a new team w/ assigned email using Client", func() { + printer.Clean() + cmd := &cobra.Command{} + teamName := model.NewId() + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", "somedisplayname", "") + email := "someemail@example.com" + cmd.Flags().String("email", email, "") + + err := createTeamCmdF(s.th.Client, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + newTeam, err := s.th.App.GetTeamByName(teamName) + s.Require().Nil(err) + s.NotEqual(email, newTeam.Email) + }) + + s.RunForAllClients("Should create a new open team", func(c client.Client) { + printer.Clean() + cmd := &cobra.Command{} + teamName := model.NewId() + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", "somedisplayname", "") + + err := createTeamCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + newTeam, err := s.th.App.GetTeamByName(teamName) + s.Require().Nil(err) + s.Equal(newTeam.Type, model.TeamOpen) + s.Equal(newTeam.AllowOpenInvite, true) + }) + + s.RunForAllClients("Should create a new private team", func(c client.Client) { + printer.Clean() + cmd := &cobra.Command{} + teamName := model.NewId() + cmd.Flags().String("name", teamName, "") + cmd.Flags().String("display-name", "somedisplayname", "") + cmd.Flags().Bool("private", true, "") + + err := createTeamCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + newTeam, err := s.th.App.GetTeamByName(teamName) + s.Require().Nil(err) + s.Equal(newTeam.Type, model.TeamInvite) + s.Equal(newTeam.AllowOpenInvite, false) + }) +} + +func (s *MmctlE2ETestSuite) TestSearchTeamCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("Search for existing team", func(c client.Client) { + printer.Clean() + + err := searchTeamCmdF(c, &cobra.Command{}, []string{s.th.BasicTeam.Name}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + team := printer.GetLines()[0].(*model.Team) + s.Equal(s.th.BasicTeam.Name, team.Name) + }) + + s.Run("Search for existing team with Client", func() { + printer.Clean() + + err := searchTeamCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicTeam.Name}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to find team '"+s.th.BasicTeam.Name+"'", printer.GetErrorLines()[0]) + }) + + s.RunForAllClients("Search of nonexistent team", func(c client.Client) { + printer.Clean() + + teamnameArg := "nonexistentteam" + err := searchTeamCmdF(c, &cobra.Command{}, []string{teamnameArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal("Unable to find team '"+teamnameArg+"'", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestArchiveTeamsCmd() { + s.SetupTestHelper().InitBasic() + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "Confirm you really want to archive the team and a DB backup has been performed.") + + s.RunForAllClients("Archive nonexistent team", func(c client.Client) { + printer.Clean() + + err := archiveTeamsCmdF(c, cmd, []string{"unknown-team"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to find team 'unknown-team'", printer.GetErrorLines()[0]) + }) + + s.RunForSystemAdminAndLocal("Archive basic team", func(c client.Client) { + printer.Clean() + + err := archiveTeamsCmdF(c, cmd, []string{s.th.BasicTeam.Name}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + team := printer.GetLines()[0].(*model.Team) + s.Require().Equal(s.th.BasicTeam.Name, team.Name) + s.Require().Len(printer.GetErrorLines(), 0) + + basicTeam, err := s.th.App.GetTeam(s.th.BasicTeam.Id) + s.Require().Nil(err) + s.Require().NotZero(basicTeam.DeleteAt) + + err = s.th.App.RestoreTeam(s.th.BasicTeam.Id) + s.Require().Nil(err) + }) + + s.Run("Archive team without permissions", func() { + printer.Clean() + + err := archiveTeamsCmdF(s.th.Client, cmd, []string{s.th.BasicTeam.Name}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Contains(printer.GetErrorLines()[0], "You do not have the appropriate permissions.") + + basicTeam, err := s.th.App.GetTeam(s.th.BasicTeam.Id) + s.Require().Nil(err) + s.Require().Zero(basicTeam.DeleteAt) + }) +} + +func (s *MmctlE2ETestSuite) TestListTeamsCmdF() { + s.SetupTestHelper().InitBasic() + mockTeamName := "mockteam" + model.NewId() + mockTeamDisplayname := "mockteam_display" + _, err := s.th.App.CreateTeam(s.th.Context, &model.Team{Name: mockTeamName, DisplayName: mockTeamDisplayname, Type: model.TeamOpen, DeleteAt: 1}) + s.Require().Nil(err) + + s.RunForSystemAdminAndLocal("Should print both active and archived teams for syasdmin and local clients", func(c client.Client) { + printer.Clean() + + err := listTeamsCmdF(c, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 2) + team := printer.GetLines()[0].(*model.Team) + s.Equal(s.th.BasicTeam.Name, team.Name) + + archivedTeam := printer.GetLines()[1].(*model.Team) + s.Equal(mockTeamName, archivedTeam.Name) + }) + + s.Run("Should not list teams for Client", func() { + printer.Clean() + + err := listTeamsCmdF(s.th.Client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestRestoreTeamsCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Restore team", func(c client.Client) { + printer.Clean() + + team := s.th.CreateTeam() + appErr := s.th.App.SoftDeleteTeam(team.Id) + s.Require().Nil(appErr) + + err := restoreTeamsCmdF(c, &cobra.Command{}, []string{team.Name}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Zero(printer.GetLines()[0].(*model.Team).DeleteAt) + }) + + s.RunForAllClients("Restore non-existent team", func(c client.Client) { + printer.Clean() + + teamName := "non-existent-team" + + err := restoreTeamsCmdF(c, &cobra.Command{}, []string{teamName}) + var expected error + errMessage := "unable to find team '" + teamName + "'" + expected = multierror.Append(expected, errors.New(errMessage)) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetErrorLines(), 1) + }) + + s.Run("Restore team without permissions", func() { + printer.Clean() + + team := s.th.CreateTeamWithClient(s.th.SystemAdminClient) + appErr := s.th.App.SoftDeleteTeam(team.Id) + s.Require().Nil(appErr) + + err := restoreTeamsCmdF(s.th.Client, &cobra.Command{}, []string{team.Name}) + var expected error + errMessage := "unable to find team '" + team.Name + "'" + expected = multierror.Append(expected, errors.New(errMessage)) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetErrorLines(), 1) + }) +} diff --git a/server/cmd/mmctl/commands/team_test.go b/server/cmd/mmctl/commands/team_test.go new file mode 100644 index 0000000000..860ae9195b --- /dev/null +++ b/server/cmd/mmctl/commands/team_test.go @@ -0,0 +1,909 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestCreateTeamCmd() { + mockTeamName := "Mock Team" + mockTeamDisplayname := "Mock Display Name" + mockTeamEmail := "mock@mattermost.com" + + s.Run("Create team with no name returns error", func() { + printer.Clean() + cmd := &cobra.Command{} + err := createTeamCmdF(s.client, cmd, []string{}) + + s.Require().Equal(err, errors.New("name is required")) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Create team with a name but no display name returns error", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("name", mockTeamName, "") + + err := createTeamCmdF(s.client, cmd, []string{}) + s.Require().Equal(err, errors.New("display Name is required")) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Create valid open team prints the created team", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("name", mockTeamName, "") + cmd.Flags().String("display-name", mockTeamDisplayname, "") + + mockTeam := &model.Team{ + Name: mockTeamName, + DisplayName: mockTeamDisplayname, + Type: model.TeamOpen, + AllowOpenInvite: true, + } + + s.client. + EXPECT(). + CreateTeam(mockTeam). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + err := createTeamCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Equal(mockTeam, printer.GetLines()[0]) + s.Require().Len(printer.GetLines(), 1) + }) + + s.Run("Create valid invite team with email prints the created team", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("name", mockTeamName, "") + cmd.Flags().String("display-name", mockTeamDisplayname, "") + cmd.Flags().String("email", mockTeamEmail, "") + cmd.Flags().Bool("private", true, "") + + mockTeam := &model.Team{ + Name: mockTeamName, + DisplayName: mockTeamDisplayname, + Email: mockTeamEmail, + Type: model.TeamInvite, + AllowOpenInvite: false, + } + + s.client. + EXPECT(). + CreateTeam(mockTeam). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + err := createTeamCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Equal(mockTeam, printer.GetLines()[0]) + s.Require().Len(printer.GetLines(), 1) + }) + + s.Run("Create returns an error when the client returns an error", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().String("name", mockTeamName, "") + cmd.Flags().String("display-name", mockTeamDisplayname, "") + + mockTeam := &model.Team{ + Name: mockTeamName, + DisplayName: mockTeamDisplayname, + Type: model.TeamOpen, + AllowOpenInvite: true, + } + mockError := errors.New("remote error") + + s.client. + EXPECT(). + CreateTeam(mockTeam). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := createTeamCmdF(s.client, cmd, []string{}) + s.Require().Equal("Team creation failed: remote error", err.Error()) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestRenameTeamCmdF() { + s.Run("Team rename should fail when unknown existing team name is entered", func() { + printer.Clean() + cmd := &cobra.Command{} + + args := []string{""} + args[0] = "existingName" + cmd.Flags().String("display-name", "newDisplayName", "Team Display Name") + + // Mocking : GetTeam searches with team id, if team not found proceeds with team name search + s.client. + EXPECT(). + GetTeam("existingName", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + // Mocking : GetTeamByname is called, if GetTeam fails to return any team, as team name was passed instead of team id + s.client. + EXPECT(). + GetTeamByName("existingName", ""). + Return(nil, &model.Response{}, nil). // Error is nil as team not found will not return error from API + Times(1) + + err := renameTeamCmdF(s.client, cmd, args) + s.Require().EqualError(err, "Unable to find team 'existingName', to see the all teams try 'team list' command") + }) + + s.Run("Team rename should fail when api fails to rename", func() { + printer.Clean() + cmd := &cobra.Command{} + + existingName := "existingTeamName" + existingDisplayName := "existingDisplayName" + newDisplayName := "NewDisplayName" + args := []string{""} + + args[0] = existingName + cmd.Flags().String("display-name", newDisplayName, "Display Name") + + // Only reduced model.Team struct for testing per say + // as we are interested in updating only name and display name + foundTeam := &model.Team{ + DisplayName: existingDisplayName, + } + renamedTeam := &model.Team{ + DisplayName: newDisplayName, + } + + s.client. + EXPECT(). + GetTeam(args[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(args[0], ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + // Some UN-foreseeable error from the api + mockError := model.NewAppError("at-random-location.go", "mock error", nil, "mocking a random error", 0) + + // Mock out UpdateTeam which calls the api to rename team + s.client. + EXPECT(). + UpdateTeam(renamedTeam). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := renameTeamCmdF(s.client, cmd, args) + s.Require().EqualError(err, "Cannot rename team '"+existingName+"', error : at-random-location.go: mock error, mocking a random error") + }) + + s.Run("Team rename should work as expected", func() { + printer.Clean() + + cmd := &cobra.Command{} + + existingName := "existingTeamName" + existingDisplayName := "existingDisplayName" + newDisplayName := "NewDisplayName" + args := []string{""} + + args[0] = existingName + cmd.Flags().String("display-name", newDisplayName, "Display Name") + + foundTeam := &model.Team{ + DisplayName: existingDisplayName, + } + updatedTeam := &model.Team{ + DisplayName: newDisplayName, + } + + s.client. + EXPECT(). + GetTeam(args[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(args[0], ""). + Return(foundTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateTeam(updatedTeam). + Return(updatedTeam, &model.Response{}, nil). + Times(1) + + err := renameTeamCmdF(s.client, cmd, args) + + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "'"+existingName+"' team renamed") + }) +} + +func (s *MmctlUnitTestSuite) TestListTeamsCmdF() { + s.Run("Error retrieving teams", func() { + printer.Clean() + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetAllTeams("", 0, APILimitMaximum). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().EqualError(err, mockError.Error()) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("One archived team", func() { + mockTeam := model.Team{ + Name: "Team1", + DeleteAt: 1, + } + + s.client. + EXPECT(). + GetAllTeams("", 0, APILimitMaximum). + Return([]*model.Team{&mockTeam}, &model.Response{}, nil). + Times(2) + + s.Run("JSON Format", func() { + printer.Clean() + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Plain Format", func() { + printer.Clean() + printer.SetFormat(printer.FormatPlain) + defer printer.SetFormat(printer.FormatJSON) + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockTeam.Name+" (archived)", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + }) + + s.Run("One non-archived team", func() { + mockTeam := model.Team{ + Name: "Team1", + } + + s.client. + EXPECT(). + GetAllTeams("", 0, APILimitMaximum). + Return([]*model.Team{&mockTeam}, &model.Response{}, nil). + Times(2) + + s.Run("JSON Format", func() { + printer.Clean() + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Plain Format", func() { + printer.Clean() + printer.SetFormat(printer.FormatPlain) + defer printer.SetFormat(printer.FormatJSON) + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockTeam.Name, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + }) + + s.Run("Several teams", func() { + mockTeams := []*model.Team{ + { + Name: "Team1", + }, + { + Name: "Team2", + DeleteAt: 1, + }, + { + Name: "Team3", + DeleteAt: 1, + }, + { + Name: "Team4", + }, + } + + s.client. + EXPECT(). + GetAllTeams("", 0, APILimitMaximum). + Return(mockTeams, &model.Response{}, nil). + Times(2) + + s.Run("JSON Format", func() { + printer.Clean() + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 4) + s.Require().Equal(mockTeams[0], printer.GetLines()[0]) + s.Require().Equal(mockTeams[1], printer.GetLines()[1]) + s.Require().Equal(mockTeams[2], printer.GetLines()[2]) + s.Require().Equal(mockTeams[3], printer.GetLines()[3]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Plain Format", func() { + printer.Clean() + printer.SetFormat(printer.FormatPlain) + defer printer.SetFormat(printer.FormatJSON) + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 4) + s.Require().Equal(mockTeams[0].Name, printer.GetLines()[0]) + s.Require().Equal(mockTeams[1].Name+" (archived)", printer.GetLines()[1]) + s.Require().Equal(mockTeams[2].Name+" (archived)", printer.GetLines()[2]) + s.Require().Equal(mockTeams[3].Name, printer.GetLines()[3]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + }) + + s.Run("Multiple team pages", func() { + printer.Clean() + + mockTeamsPage1 := make([]*model.Team, APILimitMaximum) + for i := 0; i < APILimitMaximum; i++ { + mockTeamsPage1[i] = &model.Team{Name: fmt.Sprintf("Team%d", i)} + } + mockTeamsPage2 := []*model.Team{{Name: fmt.Sprintf("Team%d", APILimitMaximum)}} + + s.client. + EXPECT(). + GetAllTeams("", 0, APILimitMaximum). + Return(mockTeamsPage1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetAllTeams("", 1, APILimitMaximum). + Return(mockTeamsPage2, &model.Response{}, nil). + Times(1) + + err := listTeamsCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), APILimitMaximum+1) + for i := 0; i < APILimitMaximum+1; i++ { + s.Require().Equal(printer.GetLines()[i].(*model.Team).Name, fmt.Sprintf("Team%d", i)) + } + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestDeleteTeamsCmd() { + teamName := "team1" + teamID := "teamId" + + s.Run("Delete teams with confirm false returns an error", func() { + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", false, "") + err := deleteTeamsCmdF(s.client, cmd, []string{"some"}) + s.Require().NotNil(err) + s.Require().Equal("could not proceed, either enable --confirm flag or use an interactive shell to complete operation: this is not an interactive shell", err.Error()) + }) + + s.Run("Delete teams with team not exist in db returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Error(err) + s.Require().Equal("Unable to find team 'team1'", printer.GetErrorLines()[0]) + }) + + s.Run("Delete teams should delete team", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteTeam(teamID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + }) + + s.Run("Delete teams with error on PermanentDeleteTeam returns an error", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + } + + mockError := errors.New("an error occurred on deleting a team") + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteTeam(teamID). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Error(err) + s.Require().Equal("Unable to delete team 'team1' error: an error occurred on deleting a team", + printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestSearchTeamCmd() { + s.Run("Search for an existing team by Name", func() { + printer.Clean() + teamName := "teamName" + mockTeam := &model.Team{Name: teamName, DisplayName: "DisplayName"} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamName}). + Return([]*model.Team{mockTeam}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{teamName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockTeam, printer.GetLines()[0]) + }) + + s.Run("Search for an existing team by DisplayName", func() { + printer.Clean() + displayName := "displayName" + mockTeam := &model.Team{Name: "teamName", DisplayName: displayName} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: displayName}). + Return([]*model.Team{mockTeam}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{displayName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(mockTeam, printer.GetLines()[0]) + }) + + s.Run("Search nonexistent team by name", func() { + printer.Clean() + teamName := "teamName" + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamName}). + Return(nil, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{teamName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to find team '"+teamName+"'", printer.GetErrorLines()[0]) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Search nonexistent team by displayName", func() { + printer.Clean() + displayName := "displayName" + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: displayName}). + Return(nil, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{displayName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Len(printer.GetLines(), 0) + s.Require().Equal("Unable to find team '"+displayName+"'", printer.GetErrorLines()[0]) + }) + + s.Run("Test search with multiple arguments", func() { + printer.Clean() + mockTeam1Name := "Mock Team 1 Name" + mockTeam2DisplayName := "Mock Team 2 displayName" + + mockTeam1 := &model.Team{Name: mockTeam1Name, DisplayName: "displayName"} + mockTeam2 := &model.Team{Name: "teamName", DisplayName: mockTeam2DisplayName} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: mockTeam1Name}). + Return([]*model.Team{mockTeam1}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: mockTeam2DisplayName}). + Return([]*model.Team{mockTeam2}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{mockTeam1Name, mockTeam2DisplayName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(mockTeam1, printer.GetLines()[0]) + s.Require().Equal(mockTeam2, printer.GetLines()[1]) + }) + + s.Run("Test get multiple results when search term matches name and displayName of different teams", func() { + printer.Clean() + teamVariableName := "Name" + + mockTeam1 := &model.Team{Name: "A", DisplayName: teamVariableName} + mockTeam2 := &model.Team{Name: teamVariableName, DisplayName: "displayName"} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamVariableName}). + Return([]*model.Team{mockTeam1, mockTeam2}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{teamVariableName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(mockTeam1, printer.GetLines()[0]) + s.Require().Equal(mockTeam2, printer.GetLines()[1]) + }) + + s.Run("Test duplicates are removed from search results", func() { + printer.Clean() + teamVariableName := "Name" + + mockTeam1 := &model.Team{Name: "team1", DisplayName: teamVariableName} + mockTeam2 := &model.Team{Name: "team2", DisplayName: teamVariableName} + mockTeam3 := &model.Team{Name: "team3", DisplayName: teamVariableName} + mockTeam4 := &model.Team{Name: "team4", DisplayName: teamVariableName} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: "team"}). + Return([]*model.Team{mockTeam1, mockTeam2, mockTeam3, mockTeam4}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamVariableName}). + Return([]*model.Team{mockTeam1, mockTeam2, mockTeam3, mockTeam4}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{"team", teamVariableName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 4) + }) + + s.Run("Test search results are sorted", func() { + printer.Clean() + teamVariableName := "Name" + + mockTeam1 := &model.Team{Name: "A", DisplayName: teamVariableName} + mockTeam2 := &model.Team{Name: "e", DisplayName: teamVariableName} + mockTeam3 := &model.Team{Name: "C", DisplayName: teamVariableName} + mockTeam4 := &model.Team{Name: "D", DisplayName: teamVariableName} + mockTeam5 := &model.Team{Name: "1", DisplayName: teamVariableName} + + s.client. + EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamVariableName}). + Return([]*model.Team{mockTeam1, mockTeam2, mockTeam3, mockTeam4, mockTeam5}, &model.Response{}, nil). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{teamVariableName}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Len(printer.GetLines(), 5) + s.Require().Equal(mockTeam5, printer.GetLines()[0]) // 1 + s.Require().Equal(mockTeam1, printer.GetLines()[1]) // A + s.Require().Equal(mockTeam3, printer.GetLines()[2]) // C + s.Require().Equal(mockTeam4, printer.GetLines()[3]) // D + s.Require().Equal(mockTeam2, printer.GetLines()[4]) // e + }) + + s.Run("Search returns an error when the client returns an error", func() { + printer.Clean() + mockError := errors.New("remote error") + teamName := "teamName" + s.client.EXPECT(). + SearchTeams(&model.TeamSearch{Term: teamName}). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := searchTeamCmdF(s.client, &cobra.Command{}, []string{teamName}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestModifyTeamsCmd() { + teamName := "team1" + teamID := "teamId" + + s.Run("Modify teams with no flags returns an error", func() { + cmd := &cobra.Command{} + cmd.Flags().Bool("private", false, "") + cmd.Flags().Bool("public", false, "") + err := modifyTeamsCmdF(s.client, cmd, []string{"some"}) + s.Require().NotNil(err) + s.Require().Equal(err.Error(), "must specify one of --private or --public") + }) + + s.Run("Modify teams with both flags returns an error", func() { + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + cmd.Flags().Bool("public", true, "") + err := modifyTeamsCmdF(s.client, cmd, []string{"some"}) + s.Require().NotNil(err) + s.Require().Equal(err.Error(), "must specify one of --private or --public") + }) + + s.Run("Modify teams with team not exist in db returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + + err := modifyTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal("Unable to find team 'team1'", printer.GetErrorLines()[0]) + }) + + s.Run("Modify teams, set to private", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + AllowOpenInvite: true, + Type: model.TeamOpen, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateTeamPrivacy(teamID, model.TeamInvite). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("private", true, "") + + err := modifyTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + }) + + s.Run("Modify teams, set to public", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + AllowOpenInvite: false, + Type: model.TeamInvite, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateTeamPrivacy(teamID, model.TeamOpen). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("public", true, "") + + err := modifyTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + }) + + s.Run("Modify teams with error on UpdateTeamPrivacy returns an error", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + AllowOpenInvite: false, + Type: model.TeamInvite, + } + + mockError := errors.New("an error occurred modifying a team") + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateTeamPrivacy(teamID, model.TeamOpen). + Return(nil, &model.Response{}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("public", true, "") + + err := modifyTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal("Unable to modify team 'team1' error: an error occurred modifying a team", + printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestRestoreTeamsCmd() { + teamName := "team1" + teamID := "teamId" + cmd := &cobra.Command{} + + s.Run("Restore teams with team not exist in db returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeamByName(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := restoreTeamsCmdF(s.client, cmd, []string{"team1"}) + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable to find team '%s'", teamName)) + + s.Require().EqualError(err, expected.Error()) + }) + + s.Run("Restore team", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + } + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreTeam(teamID). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + err := restoreTeamsCmdF(s.client, cmd, []string{"team1"}) + s.Require().Nil(err) + s.Require().Equal(&mockTeam, printer.GetLines()[0]) + }) + + s.Run("Restore team with error on RestoreTeam returns an error", func() { + printer.Clean() + mockTeam := model.Team{ + Id: teamID, + Name: teamName, + } + + mockError := errors.New("an error occurred restoring a team") + + s.client. + EXPECT(). + GetTeam(teamName, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + RestoreTeam(teamID). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := restoreTeamsCmdF(s.client, cmd, []string{"team1"}) + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable to restore team '%s' error: an error occurred restoring a team", teamName)) + + s.Require().EqualError(err, expected.Error()) + }) +} diff --git a/server/cmd/mmctl/commands/team_users.go b/server/cmd/mmctl/commands/team_users.go new file mode 100644 index 0000000000..570210a3eb --- /dev/null +++ b/server/cmd/mmctl/commands/team_users.go @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +var TeamUsersCmd = &cobra.Command{ + Use: "users", + Short: "Management of team users", +} + +var TeamUsersRemoveCmd = &cobra.Command{ + Use: "remove [team] [users]", + Short: "Remove users from team", + Long: "Remove some users from team", + Example: " team users remove myteam user@example.com username", + Args: cobra.MinimumNArgs(2), + RunE: withClient(teamUsersRemoveCmdF), +} + +var TeamUsersAddCmd = &cobra.Command{ + Use: "add [team] [users]", + Short: "Add users to team", + Long: "Add some users to team", + Example: " team users add myteam user@example.com username", + Args: cobra.MinimumNArgs(2), + RunE: withClient(teamUsersAddCmdF), +} + +func init() { + TeamUsersCmd.AddCommand( + TeamUsersRemoveCmd, + TeamUsersAddCmd, + ) + + TeamCmd.AddCommand(TeamUsersCmd) +} + +func teamUsersRemoveCmdF(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] + "'") + } + + var errs *multierror.Error + users := getUsersFromUserArgs(c, args[1:]) + for i, user := range users { + if err := removeUserFromTeam(c, team, user, args[i+1]); err != nil { + errs = multierror.Append(errs, err) + } + } + + return errs.ErrorOrNil() +} + +func removeUserFromTeam(c client.Client, team *model.Team, user *model.User, userArg string) error { + if user == nil { + err := fmt.Errorf("can't find user '%s'", userArg) + printer.PrintError(err.Error()) + return err + } + + var err error + if _, err = c.RemoveTeamMember(team.Id, user.Id); err != nil { + err = fmt.Errorf("unable to remove '%s' from %s. Error: %w", userArg, team.Name, err) + printer.PrintError(err.Error()) + } + + return err +} + +func teamUsersAddCmdF(c client.Client, cmd *cobra.Command, args []string) error { + var errs *multierror.Error + team := getTeamFromTeamArg(c, args[0]) + if team == nil { + return errors.New("Unable to find team '" + args[0] + "'") + } + + users := getUsersFromUserArgs(c, args[1:]) + for i, user := range users { + if user == nil { + userErr := fmt.Errorf("can't find user '%s'", args[i+1]) + printer.PrintError(userErr.Error()) + errs = multierror.Append(errs, userErr) + continue + } + addUserToTeam(c, team, user, args[i+1]) + } + + return errs.ErrorOrNil() +} + +func addUserToTeam(c client.Client, team *model.Team, user *model.User, userArg string) { + if _, _, err := c.AddTeamMember(team.Id, user.Id); err != nil { + printer.PrintError("Unable to add '" + userArg + "' to " + team.Name + ". Error: " + err.Error()) + } +} diff --git a/server/cmd/mmctl/commands/team_users_e2e_test.go b/server/cmd/mmctl/commands/team_users_e2e_test.go new file mode 100644 index 0000000000..4c2255f032 --- /dev/null +++ b/server/cmd/mmctl/commands/team_users_e2e_test.go @@ -0,0 +1,232 @@ +// 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/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/api4" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestTeamUserAddCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + team, appErr := s.th.App.CreateTeam(s.th.Context, &model.Team{ + DisplayName: "dn_" + model.NewId(), + Name: api4.GenerateTestTeamName(), + Email: s.th.GenerateTestEmail(), + Type: model.TeamOpen, + }) + s.Require().Nil(appErr) + + unlinkUserFromTeam := func(teamId string, userId string) error { + teamMembers, err := s.th.App.GetTeamMembers(teamId, 0, 10, nil) + if err != nil { + return err + } + var teamMember *model.TeamMember + for _, v := range teamMembers { + if v.UserId == userId { + teamMember = v + break + } + } + if teamMember == nil { + return nil + } + return s.th.App.RemoveUserFromTeam(s.th.Context, teamId, teamMember.UserId, s.th.SystemAdminUser.Id) + } + + s.RunForSystemAdminAndLocal("Add user to team", func(c client.Client) { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + err := teamUsersAddCmdF(c, &cobra.Command{}, []string{team.Id, user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + teamMembers, err := s.th.App.GetTeamMembers(team.Id, 0, 10, nil) + s.Require().Nil(err) + s.Require().NotNil(teamMembers) + s.Require().Len(teamMembers, 1) + s.Require().Equal(user.Id, teamMembers[0].UserId) + }) + + s.Run("Add user to team without permissions", func() { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + err := teamUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{team.Id, user.Email}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(fmt.Sprintf("Unable to find team '%s'", team.Id), err.Error()) + + teamMembers, err := s.th.App.GetTeamMembers(team.Id, 0, 10, nil) + s.Require().Nil(err) + s.Require().Len(teamMembers, 0) + }) + + s.Run("Add user to team with permissions", func() { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + _, appErr = s.th.App.AddTeamMember(s.th.Context, team.Id, s.th.BasicUser.Id) + s.Require().Nil(appErr) + defer func() { + appErr = unlinkUserFromTeam(team.Id, s.th.BasicUser.Id) + s.Require().Nil(appErr) + }() + + err := teamUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{team.Id, user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + teamMembers, err := s.th.App.GetTeamMembers(team.Id, 0, 10, nil) + s.Require().Nil(err) + s.Require().NotNil(teamMembers) + s.Require().Len(teamMembers, 2) + + var teamUsersID []string + for _, v := range teamMembers { + teamUsersID = append(teamUsersID, v.UserId) + } + s.Require().Contains(teamUsersID, user.Id) + }) + + s.RunForSystemAdminAndLocal("Add user to nonexistent team", func(c client.Client) { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + nonexistentTeamName := "nonexistent" + err := teamUsersAddCmdF(c, &cobra.Command{}, []string{nonexistentTeamName, user.Email}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(fmt.Sprintf("Unable to find team '%s'", nonexistentTeamName), err.Error()) + }) + + s.RunForSystemAdminAndLocal("Add nonexistent user to team", func(c client.Client) { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + nonexistentUserEmail := "nonexistent@email" + var expectedError error + expectedError = multierror.Append(expectedError, fmt.Errorf("can't find user '%s'", nonexistentUserEmail)) + err := teamUsersAddCmdF(c, &cobra.Command{}, []string{team.Id, nonexistentUserEmail}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Len(printer.GetLines(), 0) + s.Require().EqualError(err, expectedError.Error()) + }) + + s.Run("Add nonexistent user to team", func() { + printer.Clean() + + appErr := unlinkUserFromTeam(team.Id, user.Id) + s.Require().Nil(appErr) + + _, appErr = s.th.App.AddTeamMember(s.th.Context, team.Id, s.th.BasicUser.Id) + s.Require().Nil(appErr) + defer func() { + appErr = unlinkUserFromTeam(team.Id, s.th.BasicUser.Id) + s.Require().Nil(appErr) + }() + + nonexistentUserEmail := "nonexistent@email" + var expectedError error + expectedError = multierror.Append(expectedError, fmt.Errorf("can't find user '%s'", nonexistentUserEmail)) + err := teamUsersAddCmdF(s.th.Client, &cobra.Command{}, []string{team.Id, nonexistentUserEmail}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().EqualError(err, expectedError.Error()) + }) +} + +func (s *MmctlE2ETestSuite) TestTeamUsersRemoveCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("Remove user from team", func(c client.Client) { + printer.Clean() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + team := model.Team{ + DisplayName: "dn_" + model.NewId(), + Name: api4.GenerateTestTeamName(), + Email: s.th.GenerateTestEmail(), + Type: model.TeamOpen, + } + _, appErr = s.th.App.CreateTeamWithUser(s.th.Context, &team, user.Id) + s.Require().Nil(appErr) + + err := teamUsersRemoveCmdF(c, &cobra.Command{}, []string{team.Name, user.Username}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + teamMembers, err := s.th.App.GetTeamMembers(team.Id, 0, 10, nil) + s.Require().Nil(err) + s.Require().NotNil(teamMembers) + s.Require().Len(teamMembers, 0) + }) + + s.RunForSystemAdminAndLocal("Remove user from non-existent team", func(c client.Client) { + printer.Clean() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + nonexistentTeamName := model.NewId() + err := teamUsersRemoveCmdF(c, &cobra.Command{}, []string{nonexistentTeamName, user.Username}) + s.Require().NotNil(err) + s.Require().Equal(err.Error(), fmt.Sprintf("Unable to find team '%s'", nonexistentTeamName)) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Remove user from team without permissions", func() { + printer.Clean() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + team := model.Team{ + DisplayName: "dn_" + model.NewId(), + Name: api4.GenerateTestTeamName(), + Email: s.th.GenerateTestEmail(), + Type: model.TeamOpen, + } + _, appErr = s.th.App.CreateTeamWithUser(s.th.Context, &team, user.Id) + s.Require().Nil(appErr) + + err := teamUsersRemoveCmdF(s.th.Client, &cobra.Command{}, []string{team.Name, user.Username}) + s.Require().NotNil(err) + s.Require().Equal(err.Error(), fmt.Sprintf("Unable to find team '%s'", team.Name)) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/team_users_test.go b/server/cmd/mmctl/commands/team_users_test.go new file mode 100644 index 0000000000..f452468df4 --- /dev/null +++ b/server/cmd/mmctl/commands/team_users_test.go @@ -0,0 +1,370 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +package commands + +import ( + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlUnitTestSuite) TestTeamUsersArchiveCmd() { + teamArg := "example-team-id" + userArg := "example-user-id" + + s.Run("Remove users from team with a non-existent team returns an error", func() { + printer.Clean() + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{teamArg, userArg}) + s.Require().Equal(err.Error(), "Unable to find team '"+teamArg+"'") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Remove users from team with a non-existent user returns an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg} + mockUser := &model.User{Id: userArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{teamArg, mockUser.Id}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "can't find user '"+userArg+"'") + }) + + s.Run("Remove users from team by email and get team by name should not return an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg} + mockUser := &model.User{Id: userArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(mockUser, nil, nil). + Times(1) + + s.client. + EXPECT(). + RemoveTeamMember(mockTeam.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{mockTeam.Id, mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Remove users from team by email and get team should not return an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg} + mockUser := &model.User{Id: userArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(mockUser, nil, nil). + Times(1) + + s.client. + EXPECT(). + RemoveTeamMember(mockTeam.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{mockTeam.Id, mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Remove users from team by username and get team should not return an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg} + mockUser := &model.User{Id: userArg} + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Id, ""). + Return(mockUser, nil, nil). + Times(1) + + s.client. + EXPECT(). + RemoveTeamMember(mockTeam.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{mockTeam.Id, mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Remove users from team by user and get team should not return an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg} + mockUser := &model.User{Id: userArg} + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Id, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(mockUser.Id, ""). + Return(mockUser, nil, nil). + Times(1) + + s.client. + EXPECT(). + RemoveTeamMember(mockTeam.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{mockTeam.Id, mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Remove users from team with an erroneous RemoveTeamMember should return an error", func() { + printer.Clean() + mockTeam := &model.Team{Id: teamArg, Name: "example-name"} + mockUser := &model.User{Id: userArg} + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamArg, ""). + Return(mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(mockUser, nil, nil). + Times(1) + + s.client. + EXPECT(). + RemoveTeamMember(mockTeam.Id, mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := teamUsersRemoveCmdF(s.client, &cobra.Command{}, []string{mockTeam.Id, mockUser.Id}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "unable to remove '"+mockUser.Id+"' from "+mockTeam.Name+". Error: "+mockError.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestAddUsersCmd() { + mockTeam := model.Team{ + Id: "TeamId", + Name: "team1", + DisplayName: "DisplayName", + } + mockUser := model.User{ + Id: "UserID", + Username: "ExampleUser", + Email: "example@example.com", + } + + s.Run("Add users with a team that cannot be found returns error", func() { + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName("team1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamUsersAddCmdF(s.client, cmd, []string{"team1", "user1"}) + s.Require().Equal(err.Error(), "Unable to find team 'team1'") + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Add users with nonexistent user in arguments prints error", func() { + printer.Clean() + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail("user1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername("user1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser("user1", ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamUsersAddCmdF(s.client, cmd, []string{"team1", "user1"}) + s.Require().Error(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().ErrorContains(err, "can't find user 'user1'") + }) + + s.Run("Add users should print error when cannot add team member", func() { + printer.Clean() + cmd := &cobra.Command{} + + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail("user1", ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + mockError := errors.New("cannot add team member") + + s.client. + EXPECT(). + AddTeamMember("TeamId", "UserID"). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := teamUsersAddCmdF(s.client, cmd, []string{"team1", "user1"}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], + "Unable to add 'user1' to team1. Error: cannot add team member") + }) + + s.Run("Add users should not print in console anything on success", func() { + printer.Clean() + + cmd := &cobra.Command{} + s.client. + EXPECT(). + GetTeam("team1", ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail("user1", ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + AddTeamMember("TeamId", "UserID"). + Return(nil, &model.Response{}, nil). + Times(1) + + err := teamUsersAddCmdF(s.client, cmd, []string{"team1", "user1"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} diff --git a/server/cmd/mmctl/commands/teamargs.go b/server/cmd/mmctl/commands/teamargs.go new file mode 100644 index 0000000000..2163394784 --- /dev/null +++ b/server/cmd/mmctl/commands/teamargs.go @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" +) + +func getTeamsFromTeamArgs(c client.Client, teamArgs []string) []*model.Team { + teams := make([]*model.Team, 0, len(teamArgs)) + for _, teamArg := range teamArgs { + team := getTeamFromTeamArg(c, teamArg) + teams = append(teams, team) + } + return teams +} + +func getTeamFromTeamArg(c client.Client, teamArg string) *model.Team { + if checkDots(teamArg) || checkSlash(teamArg) { + return nil + } + + var team *model.Team + team, _, _ = c.GetTeam(teamArg, "") + + if team == nil { + team, _, _ = c.GetTeamByName(teamArg, "") + } + return team +} + +// getTeamsFromArgs obtains teams given `teamArgs` parameter. It can return +// teams and errors at the same time +// +//nolint:golint,unused +func getTeamsFromArgs(c client.Client, teamArgs []string) ([]*model.Team, error) { + var teams []*model.Team + var result *multierror.Error + for _, arg := range teamArgs { + team, err := getTeamFromArg(c, arg) + if err != nil { + result = multierror.Append(result, err) + continue + } + teams = append(teams, team) + } + return teams, result.ErrorOrNil() +} + +//nolint:golint,unused +func getTeamFromArg(c client.Client, teamArg string) (*model.Team, error) { + if checkDots(teamArg) || checkSlash(teamArg) { + return nil, fmt.Errorf("invalid argument %q", teamArg) + } + var team *model.Team + var response *model.Response + var err error + team, response, err = c.GetTeam(teamArg, "") + 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 team != nil { + return team, nil + } + team, response, err = c.GetTeamByName(teamArg, "") + 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 team == nil { + return nil, ErrEntityNotFound{Type: "team", ID: teamArg} + } + return team, nil +} diff --git a/server/cmd/mmctl/commands/teamargs_test.go b/server/cmd/mmctl/commands/teamargs_test.go new file mode 100644 index 0000000000..84f1963a85 --- /dev/null +++ b/server/cmd/mmctl/commands/teamargs_test.go @@ -0,0 +1,100 @@ +// 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/server/public/model" + "github.com/pkg/errors" +) + +func (s *MmctlUnitTestSuite) TestGetTeamArgs() { + s.Run("team not found", func() { + notFoundTeam := "notfoundteam" + notFoundErr := errors.New("team not found") + + s.client. + EXPECT(). + GetTeam(notFoundTeam, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). + Times(1) + s.client. + EXPECT(). + GetTeamByName(notFoundTeam, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). + Times(1) + + teams, err := getTeamsFromArgs(s.client, []string{notFoundTeam}) + s.Require().Empty(teams) + s.Require().NotNil(err) + s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* team %s not found\n\n", notFoundTeam)) + }) + s.Run("bad request", func() { + badRequestTeam := "badrequest" + badRequestErr := errors.New("team bad request") + + s.client. + EXPECT(). + GetTeam(badRequestTeam, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). + Times(1) + s.client. + EXPECT(). + GetTeamByName(badRequestTeam, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). + Times(1) + + teams, err := getTeamsFromArgs(s.client, []string{badRequestTeam}) + s.Require().Empty(teams) + s.Require().NotNil(err) + s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* team %s not found\n\n", badRequestTeam)) + }) + s.Run("forbidden", func() { + forbidden := "forbidden" + forbiddenErr := errors.New("team forbidden") + + s.client. + EXPECT(). + GetTeam(forbidden, ""). + Return(nil, &model.Response{StatusCode: http.StatusForbidden}, forbiddenErr). + Times(1) + + teams, err := getTeamsFromArgs(s.client, []string{forbidden}) + s.Require().Empty(teams) + s.Require().NotNil(err) + s.Require().EqualError(err, "1 error occurred:\n\t* team forbidden\n\n") + }) + s.Run("internal server error", func() { + errTeam := "internalServerError" + internalServerErrorErr := errors.New("team internalServerError") + + s.client. + EXPECT(). + GetTeam(errTeam, ""). + Return(nil, &model.Response{StatusCode: http.StatusInternalServerError}, internalServerErrorErr). + Times(1) + + teams, err := getTeamsFromArgs(s.client, []string{errTeam}) + s.Require().Empty(teams) + s.Require().NotNil(err) + s.Require().EqualError(err, "1 error occurred:\n\t* team internalServerError\n\n") + }) + s.Run("success", func() { + successID := "success@success.com" + successTeam := &model.Team{Id: successID} + + s.client. + EXPECT(). + GetTeam(successID, ""). + Return(successTeam, nil, nil). + Times(1) + + teams, summary := getTeamsFromArgs(s.client, []string{successID}) + s.Require().Nil(summary) + s.Require().Len(teams, 1) + s.Require().Equal(successTeam, teams[0]) + }) +} diff --git a/server/cmd/mmctl/commands/token.go b/server/cmd/mmctl/commands/token.go new file mode 100644 index 0000000000..846ac08908 --- /dev/null +++ b/server/cmd/mmctl/commands/token.go @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var TokenCmd = &cobra.Command{ + Use: "token", + Short: "manage users' access tokens", +} + +var GenerateUserTokenCmd = &cobra.Command{ + Use: "generate [user] [description]", + Short: "Generate token for a user", + Long: "Generate token for a user", + Example: " generate testuser test-token", + RunE: withClient(generateTokenForAUserCmdF), + Args: cobra.ExactArgs(2), +} + +var RevokeUserTokenCmd = &cobra.Command{ + Use: "revoke [token-ids]", + Short: "Revoke tokens for a user", + Long: "Revoke tokens for a user", + Example: " revoke testuser test-token-id", + RunE: withClient(revokeTokenForAUserCmdF), + Args: cobra.MinimumNArgs(1), +} + +var ListUserTokensCmd = &cobra.Command{ + Use: "list [user]", + Short: "List users tokens", + Long: "List the tokens of a user", + Example: " user tokens testuser", + RunE: withClient(listTokensOfAUserCmdF), + Args: cobra.ExactArgs(1), +} + +func init() { + ListUserTokensCmd.Flags().Int("page", 0, "Page number to fetch for the list of users") + ListUserTokensCmd.Flags().Int("per-page", 200, "Number of users to be fetched") + ListUserTokensCmd.Flags().Bool("all", false, "Fetch all tokens. --page flag will be ignore if provided") + ListUserTokensCmd.Flags().Bool("active", true, "List only active tokens") + ListUserTokensCmd.Flags().Bool("inactive", false, "List only inactive tokens") + + TokenCmd.AddCommand( + GenerateUserTokenCmd, + RevokeUserTokenCmd, + ListUserTokensCmd, + ) + + RootCmd.AddCommand( + TokenCmd, + ) +} + +func generateTokenForAUserCmdF(c client.Client, command *cobra.Command, args []string) error { + userArg := args[0] + user := getUserFromUserArg(c, userArg) + if user == nil { + return errors.Errorf("could not retrieve user information of %q", userArg) + } + + token, _, err := c.CreateUserAccessToken(user.Id, args[1]) + if err != nil { + return errors.Errorf("could not create token for %q: %s", userArg, err.Error()) + } + printer.PrintT("{{.Token}}: {{.Description}}", token) + + return nil +} + +func listTokensOfAUserCmdF(c client.Client, command *cobra.Command, args []string) error { + page, _ := command.Flags().GetInt("page") + perPage, _ := command.Flags().GetInt("per-page") + showAll, _ := command.Flags().GetBool("all") + active, _ := command.Flags().GetBool("active") + inactive, _ := command.Flags().GetBool("inactive") + + if showAll { + page = 0 + perPage = 9999 + } + + userArg := args[0] + + user := getUserFromUserArg(c, userArg) + if user == nil { + return errors.Errorf("could not retrieve user information of %q", userArg) + } + + tokens, _, err := c.GetUserAccessTokensForUser(user.Id, page, perPage) + if err != nil { + return errors.Errorf("could not retrieve tokens for user %q: %s", userArg, err.Error()) + } + + if len(tokens) == 0 { + return errors.Errorf("there are no tokens for the %q", userArg) + } + + for _, t := range tokens { + if t.IsActive && !inactive { + printer.PrintT("{{.Id}}: {{.Description}}", t) + } + if !t.IsActive && !active { + printer.PrintT("{{.Id}}: {{.Description}}", t) + } + } + return nil +} + +func revokeTokenForAUserCmdF(c client.Client, command *cobra.Command, args []string) error { + for _, id := range args { + res, err := c.RevokeUserAccessToken(id) + if err != nil { + return errors.Errorf("could not revoke token %q: %s", id, err.Error()) + } + if res.StatusCode != http.StatusOK { + return errors.Errorf("could not revoke token %q", id) + } + } + return nil +} diff --git a/server/cmd/mmctl/commands/token_e2e_test.go b/server/cmd/mmctl/commands/token_e2e_test.go new file mode 100644 index 0000000000..d4a1ab8404 --- /dev/null +++ b/server/cmd/mmctl/commands/token_e2e_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestTokenGenerateForUserCmd() { + s.SetupTestHelper().InitBasic() + + tokenDescription := model.NewRandomString(10) + + previousVal := s.th.App.Config().ServiceSettings.EnableUserAccessTokens + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true }) + defer s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = *previousVal }) + + s.RunForSystemAdminAndLocal("Generate token for user", func(c client.Client) { + printer.Clean() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + err := generateTokenForAUserCmdF(c, &cobra.Command{}, []string{user.Email, tokenDescription}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 0) + + userTokens, appErr := s.th.App.GetUserAccessTokensForUser(user.Id, 0, 1) + s.Require().Nil(appErr) + s.Require().Equal(1, len(userTokens)) + + userToken, appErr := s.th.App.GetUserAccessToken(userTokens[0].Id, false) + s.Require().Nil(appErr) + + expectedUserToken := printer.GetLines()[0].(*model.UserAccessToken) + + s.Require().Equal(expectedUserToken, userToken) + }) + + s.RunForSystemAdminAndLocal("Generate token for nonexistent user", func(c client.Client) { + printer.Clean() + + nonExistentUserEmail := s.th.GenerateTestEmail() + + err := generateTokenForAUserCmdF(c, &cobra.Command{}, []string{nonExistentUserEmail, tokenDescription}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal( + fmt.Sprintf(`could not retrieve user information of %q`, nonExistentUserEmail), + err.Error()) + }) + + s.Run("Generate token without permission", func() { + printer.Clean() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + err := generateTokenForAUserCmdF(s.th.Client, &cobra.Command{}, []string{user.Email, tokenDescription}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().ErrorContains( + err, + fmt.Sprintf(`could not create token for %q: : You do not have the appropriate permissions.`, user.Email), + ) + userTokens, appErr := s.th.App.GetUserAccessTokensForUser(user.Id, 0, 1) + s.Require().Nil(appErr) + s.Require().Equal(0, len(userTokens)) + }) +} diff --git a/server/cmd/mmctl/commands/token_test.go b/server/cmd/mmctl/commands/token_test.go new file mode 100644 index 0000000000..303bee41c3 --- /dev/null +++ b/server/cmd/mmctl/commands/token_test.go @@ -0,0 +1,296 @@ +// 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/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestGenerateTokenForAUserCmd() { + s.Run("Should generate a token for a user", func() { + printer.Clean() + + userArg := "userId1" + mockUser := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + mockToken := model.UserAccessToken{Token: "token-id", Description: "token-desc"} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateUserAccessToken(mockUser.Id, mockToken.Description). + Return(&mockToken, &model.Response{}, nil). + Times(1) + + err := generateTokenForAUserCmdF(s.client, &cobra.Command{}, []string{mockUser.Id, mockToken.Description}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockToken, printer.GetLines()[0]) + }) + + s.Run("Should fail on an invalid username", func() { + printer.Clean() + + userArg := "some-text" + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given ID")). + Times(1) + + err := generateTokenForAUserCmdF(s.client, &cobra.Command{}, []string{userArg, "description"}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), fmt.Sprintf("could not retrieve user information of %q", userArg)) + }) + + s.Run("Should fail if can't create tokens for a valid user", func() { + printer.Clean() + + userArg := "user1" + mockUser := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateUserAccessToken(mockUser.Id, "description"). + Return(nil, &model.Response{}, errors.New("error-message")). + Times(1) + + err := generateTokenForAUserCmdF(s.client, &cobra.Command{}, []string{"user1", "description"}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), fmt.Sprintf("could not create token for %q:", "user1")) + }) +} + +func (s *MmctlUnitTestSuite) TestListTokensOfAUserCmdF() { + s.Run("Should list access tokens of a user", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().Int("page", 0, "") + command.Flags().Int("per-page", 2, "") + command.Flags().Bool("all", true, "") + command.Flags().Bool("active", false, "") + command.Flags().Bool("inactive", false, "") + + mockUser := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + mockToken1 := model.UserAccessToken{IsActive: true, Id: "token-1-id", Description: "token-1-desc"} + mockToken2 := model.UserAccessToken{IsActive: false, Id: "token-2-id", Description: "token-2-desc"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Id, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(mockUser.Id, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserAccessTokensForUser(mockUser.Id, 0, 9999). + Return( + []*model.UserAccessToken{&mockToken1, &mockToken2}, + &model.Response{}, nil, + ).Times(1) + + err := listTokensOfAUserCmdF(s.client, &command, []string{mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(&mockToken1, printer.GetLines()[0]) + s.Require().Equal(&mockToken2, printer.GetLines()[1]) + }) + + s.Run("Should list only active user access tokens of a user", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().Int("page", 0, "") + command.Flags().Int("per-page", 2, "") + command.Flags().Bool("all", false, "") + command.Flags().Bool("active", true, "") + command.Flags().Bool("inactive", false, "") + + mockUser := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + mockToken1 := model.UserAccessToken{IsActive: true, Id: "token-1-id", Description: "token-1-desc"} + mockToken2 := model.UserAccessToken{IsActive: false, Id: "token-2-id", Description: "token-2-desc"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(&mockUser, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserAccessTokensForUser(mockUser.Id, 0, 2). + Return( + []*model.UserAccessToken{&mockToken1, &mockToken2}, + &model.Response{}, nil, + ).Times(1) + + err := listTokensOfAUserCmdF(s.client, &command, []string{mockUser.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockToken1, printer.GetLines()[0]) + }) + + s.Run("Should err on a absent user", func() { + printer.Clean() + + userArg := "test-user" + command := cobra.Command{} + command.Flags().Int("page", 0, "") + command.Flags().Int("per-page", 2, "") + command.Flags().Bool("all", false, "") + command.Flags().Bool("active", false, "") + command.Flags().Bool("inactive", false, "") + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(nil, &model.Response{}, errors.New("no user found with the given user ID")). + Times(1) + + err := listTokensOfAUserCmdF(s.client, &command, []string{userArg}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), fmt.Sprintf("could not retrieve user information of %q", userArg)) + }) + + s.Run("Should error if there are no user access tokens for a valid user", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().Int("page", 0, "") + command.Flags().Int("per-page", 2, "") + command.Flags().Bool("all", false, "") + command.Flags().Bool("active", true, "") + command.Flags().Bool("inactive", false, "") + + mockUser := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Email, ""). + Return(&mockUser, &model.Response{}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserAccessTokensForUser(mockUser.Id, 0, 2). + Return( + []*model.UserAccessToken{}, + &model.Response{}, nil, + ).Times(1) + + err := listTokensOfAUserCmdF(s.client, &command, []string{mockUser.Email}) + s.Require().NotNil(err) + s.Require().Equal(err.Error(), fmt.Sprintf("there are no tokens for the %q", mockUser.Email)) + }) +} + +func (s *MmctlUnitTestSuite) TestRevokeTokenForAUserCmdF() { + s.Run("Should revoke user access tokens", func() { + printer.Clean() + + mockToken1 := model.UserAccessToken{Id: "123456"} + mockToken2 := model.UserAccessToken{Id: "234567"} + + s.client. + EXPECT(). + RevokeUserAccessToken(mockToken1.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + RevokeUserAccessToken(mockToken2.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := revokeTokenForAUserCmdF(s.client, &cobra.Command{}, []string{mockToken1.Id, mockToken2.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Should fail if can't revoke user access token", func() { + s.client. + EXPECT(). + RevokeUserAccessToken("token-id"). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := revokeTokenForAUserCmdF(s.client, &cobra.Command{}, []string{"token-id"}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), fmt.Sprintf("could not revoke token %q", "token-id")) + }) +} diff --git a/server/cmd/mmctl/commands/user.go b/server/cmd/mmctl/commands/user.go new file mode 100644 index 0000000000..e2d0caffdc --- /dev/null +++ b/server/cmd/mmctl/commands/user.go @@ -0,0 +1,1000 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var UserCmd = &cobra.Command{ + Use: "user", + Short: "Management of users", +} + +var UserActivateCmd = &cobra.Command{ + Use: "activate [emails, usernames, userIds]", + Short: "Activate users", + Long: "Activate users that have been deactivated.", + Example: ` user activate user@example.com + user activate username`, + RunE: withClient(userActivateCmdF), + Args: cobra.MinimumNArgs(1), +} + +var UserDeactivateCmd = &cobra.Command{ + Use: "deactivate [emails, usernames, userIds]", + Short: "Deactivate users", + Long: "Deactivate users. Deactivated users are immediately logged out of all sessions and are unable to log back in.", + Example: ` user deactivate user@example.com + user deactivate username`, + RunE: withClient(userDeactivateCmdF), + Args: cobra.MinimumNArgs(1), +} + +var UserCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a user", + Long: "Create a user", + Example: ` # You can create a user + $ mmctl user create --email user@example.com --username userexample --password Password1 + + # You can define optional fields like first name, last name and nick name too + $ mmctl user create --email user@example.com --username userexample --password Password1 --firstname User --lastname Example --nickname userex + + # Also you can create the user as system administrator + $ mmctl user create --email user@example.com --username userexample --password Password1 --system-admin + + # Finally you can verify user on creation if you have enough permissions + $ mmctl user create --email user@example.com --username userexample --password Password1 --system-admin --email-verified`, + RunE: withClient(userCreateCmdF), +} + +var UserInviteCmd = &cobra.Command{ + Use: "invite [email] [teams]", + Short: "Send user an email invite to a team.", + Long: `Send user an email invite to a team. +You can invite a user to multiple teams by listing them. +You can specify teams by name or ID.`, + Example: ` user invite user@example.com myteam + user invite user@example.com myteam1 myteam2`, + RunE: withClient(userInviteCmdF), +} + +var SendPasswordResetEmailCmd = &cobra.Command{ + Use: "reset-password [users]", + Aliases: []string{"reset_password"}, + Short: "Send users an email to reset their password", + Long: "Send users an email to reset their password", + Example: " user reset-password user@example.com", + RunE: withClient(sendPasswordResetEmailCmdF), +} + +var UpdateUserEmailCmd = &cobra.Command{ + Use: "email [user] [new email]", + Short: "Change email of the user", + Long: "Change the email address associated with a user.", + Example: " user email testuser user@example.com", + RunE: withClient(updateUserEmailCmdF), +} + +var UpdateUsernameCmd = &cobra.Command{ + Use: "username [user] [new username]", + Short: "Change username of the user", + Long: "Change username of the user.", + Example: " user username testuser newusername", + Args: cobra.ExactArgs(2), + RunE: withClient(updateUsernameCmdF), +} + +var ChangePasswordUserCmd = &cobra.Command{ + Use: "change-password ", + Short: "Changes a user's password", + Long: "Changes the password of a user by a new one provided. If the user is changing their own password, the flag --current must indicate the current password. The flag --hashed can be used to indicate that the new password has been introduced already hashed", + Example: ` # if you have system permissions, you can change other user's passwords + $ mmctl user change-password john_doe --password new-password + + # if you are changing your own password, you need to provide the current one + $ mmctl user change-password my-username --current current-password --password new-password + + # you can ommit these flags to introduce them interactively + $ mmctl user change-password my-username + Are you changing your own password? (YES/NO): YES + Current password: + New password: + + # if you have system permissions, you can update the password with the already hashed new + # password. The hashing method should be the same that the server uses internally + $ mmctl user change-password john_doe --password HASHED_PASSWORD --hashed`, + Args: cobra.ExactArgs(1), + RunE: withClient(changePasswordUserCmdF), +} + +var ResetUserMfaCmd = &cobra.Command{ + Use: "resetmfa [users]", + Short: "Turn off MFA", + Long: `Turn off multi-factor authentication for a user. +If MFA enforcement is enabled, the user will be forced to re-enable MFA as soon as they log in.`, + Example: " user resetmfa user@example.com", + RunE: withClient(resetUserMfaCmdF), +} + +var DeleteUsersCmd = &cobra.Command{ + Use: "delete [users]", + Short: "Delete users", + Long: `Permanently delete some users. +Permanently deletes one or multiple users along with all related information including posts from the database.`, + Example: " user delete user@example.com", + Args: cobra.MinimumNArgs(1), + RunE: withClient(deleteUsersCmdF), +} + +var DeleteAllUsersCmd = &cobra.Command{ + Use: "deleteall", + Short: "Delete all users and all posts. Local command only.", + Long: "Permanently delete all users and all related information including posts. This command can only be run in local mode.", + Example: " user deleteall", + Args: cobra.NoArgs, + PreRun: localOnlyPrecheck, + RunE: withClient(deleteAllUsersCmdF), +} + +var SearchUserCmd = &cobra.Command{ + Use: "search [users]", + Short: "Search for users", + Long: "Search for users based on username, email, or user ID.", + Example: " user search user1@mail.com user2@mail.com", + RunE: withClient(searchUserCmdF), +} + +var ListUsersCmd = &cobra.Command{ + Use: "list", + Short: "List users", + Long: "List all users", + Example: " user list", + RunE: withClient(listUsersCmdF), + Args: cobra.NoArgs, +} + +var VerifyUserEmailWithoutTokenCmd = &cobra.Command{ + Use: "verify [users]", + Short: "Mark user's email as verified", + Long: "Mark user's email as verified without requiring user to complete email verification path.", + Example: " user verify user1", + RunE: withClient(verifyUserEmailWithoutTokenCmdF), + Args: cobra.MinimumNArgs(1), +} + +var PromoteGuestToUserCmd = &cobra.Command{ + Use: "promote [guests]", + Short: "Promote guests to users", + Long: "Convert a guest into a regular user.", + Example: " user promote guest1 guest2", + RunE: withClient(promoteGuestToUserCmdF), + Args: cobra.MinimumNArgs(1), +} + +var DemoteUserToGuestCmd = &cobra.Command{ + Use: "demote [users]", + Short: "Demote users to guests", + Long: "Convert a regular user into a guest.", + Example: " user demote user1 user2", + RunE: withClient(demoteUserToGuestCmdF), + Args: cobra.MinimumNArgs(1), +} + +var UserConvertCmd = &cobra.Command{ + Use: "convert (--bot [emails] [usernames] [userIds] | --user --password PASSWORD [--email EMAIL])", + Short: "Convert users to bots, or a bot to a user", + Long: "Convert user accounts to bots or convert bots to user accounts.", + Example: ` # you can convert a user to a bot providing its email, id or username + $ mmctl user convert user@example.com --bot + + # or multiple users in one go + $ mmctl user convert user@example.com anotherUser --bot + + # you can convert a bot to a user specifying the email and password that the user will have after conversion + $ mmctl user convert botusername --email new.email@email.com --password password --user`, + RunE: withClient(userConvertCmdF), + Args: cobra.MinimumNArgs(1), +} + +var MigrateAuthCmd = &cobra.Command{ + Use: "migrate-auth [from_auth] [to_auth] [migration-options]", + Aliases: []string{"migrate_auth"}, + Short: "Mass migrate user accounts authentication type", + Long: `Migrates accounts from one authentication provider to another. For example, you can upgrade your authentication provider from email to ldap.`, + Example: "user migrate-auth email saml users.json", + Args: func(command *cobra.Command, args []string) error { + if len(args) < 2 { + return errors.New("auth migration requires at least 2 arguments") + } + + toAuth := args[1] + + if toAuth != "ldap" && toAuth != "saml" { // nolint: goconst + return errors.New("invalid to_auth parameter, must be saml or ldap") + } + + if toAuth == "ldap" && len(args) != 3 { + return errors.New("ldap migration requires 3 arguments") + } + + autoFlag, _ := command.Flags().GetBool("auto") + + if toAuth == "saml" && autoFlag { + if len(args) != 2 { + return errors.New("saml migration requires two arguments when using the --auto flag") + } + } + + if toAuth == "saml" && !autoFlag { + if len(args) != 3 { + return errors.New("saml migration requires three arguments when not using the --auto flag") + } + } + return nil + }, + RunE: withClient(migrateAuthCmdF), +} + +func init() { + UserCreateCmd.Flags().String("username", "", "Required. Username for the new user account") + _ = UserCreateCmd.MarkFlagRequired("username") + UserCreateCmd.Flags().String("email", "", "Required. The email address for the new user account") + _ = UserCreateCmd.MarkFlagRequired("email") + UserCreateCmd.Flags().String("password", "", "Required. The password for the new user account") + _ = UserCreateCmd.MarkFlagRequired("password") + UserCreateCmd.Flags().String("nickname", "", "Optional. The nickname for the new user account") + UserCreateCmd.Flags().String("firstname", "", "Optional. The first name for the new user account") + UserCreateCmd.Flags().String("lastname", "", "Optional. The last name for the new user account") + UserCreateCmd.Flags().String("locale", "", "Optional. The locale (ex: en, fr) for the new user account") + UserCreateCmd.Flags().Bool("system-admin", false, "Optional. If supplied, the new user will be a system administrator. Defaults to false") + UserCreateCmd.Flags().Bool("system_admin", false, "") + _ = UserCreateCmd.Flags().MarkDeprecated("system_admin", "please use system-admin instead") + UserCreateCmd.Flags().Bool("guest", false, "Optional. If supplied, the new user will be a guest. Defaults to false") + UserCreateCmd.Flags().Bool("email-verified", false, "Optional. If supplied, the new user will have the email verified. Defaults to false") + UserCreateCmd.Flags().Bool("email_verified", false, "") + _ = UserCreateCmd.Flags().MarkDeprecated("email_verified", "please use email-verified instead") + UserCreateCmd.Flags().Bool("disable-welcome-email", false, "Optional. If supplied, the new user will not receive a welcome email. Defaults to false") + + DeleteUsersCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the user and a DB backup has been performed") + DeleteAllUsersCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the user and a DB backup has been performed") + + ListUsersCmd.Flags().Int("page", 0, "Page number to fetch for the list of users") + ListUsersCmd.Flags().Int("per-page", 200, "Number of users to be fetched") + ListUsersCmd.Flags().Bool("all", false, "Fetch all users. --page flag will be ignore if provided") + ListUsersCmd.Flags().String("team", "", "If supplied, only users belonging to this team will be listed") + + UserConvertCmd.Flags().Bool("bot", false, "If supplied, convert users to bots") + UserConvertCmd.Flags().Bool("user", false, "If supplied, convert a bot to a user") + UserConvertCmd.Flags().String("password", "", "The password for converted new user account. Required when \"user\" flag is set") + UserConvertCmd.Flags().String("username", "", "Username for the converted user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().String("email", "", "The email address for the converted user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().String("nickname", "", "The nickname for the converted user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().String("firstname", "", "The first name for the converted user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().String("lastname", "", "The last name for the converted user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().String("locale", "", "The locale (ex: en, fr) for converted new user account. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().Bool("system-admin", false, "If supplied, the converted user will be a system administrator. Defaults to false. Required when the \"bot\" flag is set") + UserConvertCmd.Flags().Bool("system_admin", false, "") + _ = UserConvertCmd.Flags().MarkDeprecated("system_admin", "please use system-admin instead") + + ChangePasswordUserCmd.Flags().StringP("current", "c", "", "The current password of the user. Use only if changing your own password") + ChangePasswordUserCmd.Flags().StringP("password", "p", "", "The new password for the user") + ChangePasswordUserCmd.Flags().Bool("hashed", false, "The supplied password is already hashed") + + MigrateAuthCmd.Flags().Bool("force", false, "Force the migration to occur even if there are duplicates on the LDAP server. Duplicates will not be migrated. (ldap only)") + MigrateAuthCmd.Flags().Bool("auto", false, "Automatically migrate all users. Assumes the usernames and emails are identical between Mattermost and SAML services. (saml only)") + MigrateAuthCmd.Flags().Bool("confirm", false, "Confirm you really want to proceed with auto migration. (saml only)") + MigrateAuthCmd.SetHelpTemplate(`Usage: + mmctl user migrate-auth [from_auth] [to_auth] [migration-options] [flags] + +Examples: + mmctl {{.Example}} + +Arguments: + from_auth: + The authentication service to migrate users accounts from. + Supported options: email, gitlab, google, ldap, office365, saml. + + to_auth: + The authentication service to migrate users to. + Supported options: ldap, saml. + + migration-options (ldap): + match_field: + The field that is guaranteed to be the same in both authentication services. For example, if the users emails are consistent set to email. + Supported options: email, username. + + migration-options (saml): + users_file: + The path of a json file with the usernames and emails of all users to migrate to SAML. The username and email must be the same that the SAML service provider store. And the email must match with the email in mattermost database. + + Example json content: + { + "usr1@email.com": "usr.one", + "usr2@email.com": "usr.two" + } + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}} +`) + + UserCmd.AddCommand( + UserActivateCmd, + UserDeactivateCmd, + UserCreateCmd, + UserInviteCmd, + SendPasswordResetEmailCmd, + UpdateUserEmailCmd, + UpdateUsernameCmd, + ChangePasswordUserCmd, + ResetUserMfaCmd, + DeleteUsersCmd, + DeleteAllUsersCmd, + SearchUserCmd, + ListUsersCmd, + VerifyUserEmailWithoutTokenCmd, + UserConvertCmd, + MigrateAuthCmd, + PromoteGuestToUserCmd, + DemoteUserToGuestCmd, + ) + + RootCmd.AddCommand(UserCmd) +} + +func userActivateCmdF(c client.Client, command *cobra.Command, args []string) error { + return changeUsersActiveStatus(c, args, true) +} + +func changeUsersActiveStatus(c client.Client, userArgs []string, active bool) error { + var multiErr *multierror.Error + users, err := getUsersFromArgs(c, userArgs) + if err != nil { + printer.PrintError(err.Error()) + multiErr = multierror.Append(multiErr, err) + } + for _, user := range users { + if err := changeUserActiveStatus(c, user, active); err != nil { + printer.PrintError(err.Error()) + multiErr = multierror.Append(multiErr, err) + } + } + return multiErr.ErrorOrNil() +} + +func changeUserActiveStatus(c client.Client, user *model.User, activate bool) error { + if !activate && user.IsSSOUser() { + printer.Print("You must also deactivate user " + user.Id + " in the SSO provider or they will be reactivated on next login or sync.") + } + if _, err := c.UpdateUserActive(user.Id, activate); err != nil { + return fmt.Errorf("unable to change activation status of user: %v", user.Id) + } + + return nil +} + +func userDeactivateCmdF(c client.Client, cmd *cobra.Command, args []string) error { + return changeUsersActiveStatus(c, args, false) +} + +func userCreateCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + username, erru := cmd.Flags().GetString("username") + if erru != nil { + return errors.Wrap(erru, "Username is required") + } + email, erre := cmd.Flags().GetString("email") + if erre != nil { + return errors.Wrap(erre, "Email is required") + } + password, errp := cmd.Flags().GetString("password") + if errp != nil { + return errors.Wrap(errp, "Password is required") + } + nickname, _ := cmd.Flags().GetString("nickname") + firstname, _ := cmd.Flags().GetString("firstname") + lastname, _ := cmd.Flags().GetString("lastname") + locale, _ := cmd.Flags().GetString("locale") + systemAdmin, _ := cmd.Flags().GetBool("system-admin") + if !systemAdmin { + systemAdmin, _ = cmd.Flags().GetBool("system_admin") + } + guest, _ := cmd.Flags().GetBool("guest") + emailVerified, _ := cmd.Flags().GetBool("email-verified") + if !emailVerified { + emailVerified, _ = cmd.Flags().GetBool("email_verified") + } + disableWelcomeEmail, _ := cmd.Flags().GetBool("disable-welcome-email") + + user := &model.User{ + Username: username, + Email: email, + Password: password, + Nickname: nickname, + FirstName: firstname, + LastName: lastname, + Locale: locale, + EmailVerified: emailVerified, + DisableWelcomeEmail: disableWelcomeEmail, + } + + ruser, _, err := c.CreateUser(user) + + if err != nil { + return errors.New("Unable to create user. Error: " + err.Error()) + } + + if systemAdmin { + if _, err := c.UpdateUserRoles(ruser.Id, "system_user system_admin"); err != nil { + return errors.New("Unable to update user roles. Error: " + err.Error()) + } + } else if guest { + if _, err := c.DemoteUserToGuest(ruser.Id); err != nil { + return errors.Wrapf(err, "Unable to demote use to guest") + } + } + + printer.PrintT("Created user {{.Username}}", ruser) + + return nil +} + +func userInviteCmdF(c client.Client, cmd *cobra.Command, args []string) error { + var errs *multierror.Error + if len(args) < 2 { + return errors.New("expected at least two arguments. See help text for details") + } + + email := args[0] + if !model.IsValidEmail(email) { + errs = multierror.Append(errs, fmt.Errorf("invalid email %q", email)) + } + + teams := getTeamsFromTeamArgs(c, args[1:]) + for i, team := range teams { + err := inviteUser(c, email, team, args[i+1]) + if err != nil { + errs = multierror.Append(errs, err) + printer.PrintError(err.Error()) + } + } + + return errs.ErrorOrNil() +} + +func inviteUser(c client.Client, email string, team *model.Team, teamArg string) error { + invites := []string{email} + if team == nil { + return fmt.Errorf("can't find team '%v'", teamArg) + } + + if _, err := c.InviteUsersToTeam(team.Id, invites); err != nil { + return errors.New("Unable to invite user with email " + email + " to team " + team.Name + ". Error: " + err.Error()) + } + + printer.Print("Invites may or may not have been sent.") + + return nil +} + +func sendPasswordResetEmailCmdF(c client.Client, cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("expected at least one argument. See help text for details") + } + + var result *multierror.Error + + for _, email := range args { + if !model.IsValidEmail(email) { + result = multierror.Append(result, fmt.Errorf("invalid email '%s'", email)) + printer.PrintError("Invalid email '" + email + "'") + continue + } + if _, err := c.SendPasswordResetEmail(email); err != nil { + result = multierror.Append(result, fmt.Errorf("unable send reset password email to email %s: %w", email, err)) + printer.PrintError("Unable send reset password email to email " + email + ". Error: " + err.Error()) + } + } + + return result.ErrorOrNil() +} + +func updateUserEmailCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + if len(args) != 2 { + return errors.New("expected two arguments. See help text for details") + } + + newEmail := args[1] + + if !model.IsValidEmail(newEmail) { + return errors.New("invalid email: '" + newEmail + "'") + } + + user, err := getUserFromArg(c, args[0]) + if err != nil { + return err + } + + user.Email = newEmail + + ruser, _, err := c.UpdateUser(user) + if err != nil { + return errors.New(err.Error()) + } + + printer.PrintT("User {{.Username}} updated successfully", ruser) + + return nil +} + +func updateUsernameCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + newUsername := args[1] + + if !model.IsValidUsername(newUsername) { + return errors.New("invalid username: '" + newUsername + "'") + } + + user := getUserFromUserArg(c, args[0]) + if user == nil { + return errors.New("unable to find user '" + args[0] + "'") + } + + user.Username = newUsername + + ruser, _, err := c.UpdateUser(user) + if err != nil { + return errors.New(err.Error()) + } + + printer.PrintT("User {{.Username}} updated successfully", ruser) + + return nil +} + +func changePasswordUserCmdF(c client.Client, cmd *cobra.Command, args []string) error { + password, _ := cmd.Flags().GetString("password") + current, _ := cmd.Flags().GetString("current") + hashed, _ := cmd.Flags().GetBool("hashed") + + if password == "" { + if err := getConfirmation("Are you changing your own password?", false); err == nil { + fmt.Printf("Current password: ") + var err error + current, err = getPasswordFromStdin() + if err != nil { + return errors.Wrap(err, "couldn't read password") + } + } + + fmt.Printf("New password: ") + var err error + password, err = getPasswordFromStdin() + if err != nil { + return errors.Wrap(err, "couldn't read password") + } + } + + user, err := getUserFromArg(c, args[0]) + if err != nil { + return err + } + + if hashed { + if _, err := c.UpdateUserHashedPassword(user.Id, password); err != nil { + return errors.Wrap(err, "changing user hashed password failed") + } + } else { + if _, err := c.UpdateUserPassword(user.Id, current, password); err != nil { + return errors.Wrap(err, "changing user password failed") + } + } + + printer.PrintT("Password for user {{.Username}} successfully changed", user) + return nil +} + +func resetUserMfaCmdF(c client.Client, cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("expected at least one argument. See help text for details") + } + + var result *multierror.Error + users, err := getUsersFromArgs(c, args) + if err != nil { + result = multierror.Append(result, err) + } + + for _, user := range users { + if _, err := c.UpdateUserMfa(user.Id, "", false); err != nil { + result = multierror.Append(result, fmt.Errorf("unable to reset user %q MFA. Error: %w", user.Id, err)) + } + } + + return result.ErrorOrNil() +} + +func deleteUsersCmdF(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 users specified? All data will be permanently deleted?", true); err != nil { + return err + } + } + + users, err := getUsersFromArgs(c, args) + if err != nil { + printer.PrintError(err.Error()) + } + for i, user := range users { + if user == nil { + printer.PrintError("Unable to find user '" + args[i] + "'") + continue + } + if res, err := c.PermanentDeleteUser(user.Id); err != nil { + printer.PrintError("Unable to delete user '" + user.Username + "' error: " + err.Error()) + } else { + // res.StatusCode is checked for 202 to identify issues with file deletion. + if res.StatusCode == http.StatusAccepted { + printer.PrintError("There were issues with deleting profile image of the user. Please delete it manually. Id: " + user.Id) + } + printer.PrintT("Deleted user '{{.Username}}'", user) + } + } + return nil +} + +func deleteAllUsersCmdF(c client.Client, cmd *cobra.Command, args []string) error { + confirmFlag, _ := cmd.Flags().GetBool("confirm") + if !confirmFlag { + if err := getConfirmation("Are you sure you want to permanently delete all user accounts?", true); err != nil { + return err + } + } + + if _, err := c.PermanentDeleteAllUsers(); err != nil { + return err + } + + defer printer.Print("All users successfully deleted") + + return nil +} + +func searchUserCmdF(c client.Client, cmd *cobra.Command, args []string) error { + printer.SetSingle(true) + + if len(args) < 1 { + return errors.New("expected at least one argument. See help text for details") + } + + users, err := getUsersFromArgs(c, args) + if err != nil { + printer.PrintError(err.Error()) + } + + for i, user := range users { + tpl := `id: {{.Id}} +username: {{.Username}} +nickname: {{.Nickname}} +position: {{.Position}} +first_name: {{.FirstName}} +last_name: {{.LastName}} +email: {{.Email}} +auth_service: {{.AuthService}}` + if i > 0 { + tpl = "------------------------------\n" + tpl + } + + printer.PrintT(tpl, user) + } + + return nil +} + +func listUsersCmdF(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 + } + showAll, err := command.Flags().GetBool("all") + if err != nil { + return err + } + teamName, err := command.Flags().GetString("team") + if err != nil { + return err + } + + if showAll { + page = 0 + } + + var team *model.Team + if teamName != "" { + var err error + team, _, err = c.GetTeamByName(teamName, "") + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to get team %s", teamName)) + } + } + + tpl := `{{.Id}}: {{.Username}} ({{.Email}})` + for { + var users []*model.User + var err error + if team != nil { + users, _, err = c.GetUsersInTeam(team.Id, page, perPage, "") + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to fetch users for team %s", teamName)) + } + } else { + users, _, err = c.GetUsers(page, perPage, "") + if err != nil { + return errors.Wrap(err, "Failed to fetch users") + } + } + if len(users) == 0 { + break + } + + for _, user := range users { + printer.PrintT(tpl, user) + } + + if !showAll { + break + } + page++ + } + + return nil +} + +func verifyUserEmailWithoutTokenCmdF(c client.Client, cmd *cobra.Command, userArgs []string) error { + var result *multierror.Error + users, err := getUsersFromArgs(c, userArgs) + if err != nil { + result = multierror.Append(result, err) + } + + for _, user := range users { + if newUser, _, err := c.VerifyUserEmailWithoutToken(user.Id); err != nil { + result = multierror.Append(result, fmt.Errorf("unable to verify user %s email: %w", user.Id, err)) + } else { + printer.PrintT("User {{.Username}} verified", newUser) + } + } + return result.ErrorOrNil() +} + +func userConvertCmdF(c client.Client, cmd *cobra.Command, userArgs []string) error { + toBot, _ := cmd.Flags().GetBool("bot") + toUser, _ := cmd.Flags().GetBool("user") + + if !(toUser || toBot) { + return fmt.Errorf("either %q flag or %q flag should be provided", "user", "bot") + } + + if toBot { + return convertUserToBot(c, cmd, userArgs) + } + + return convertBotToUser(c, cmd, userArgs) +} + +func convertUserToBot(c client.Client, _ *cobra.Command, userArgs []string) error { + users, err := getUsersFromArgs(c, userArgs) + if err != nil { + printer.PrintError(err.Error()) + } + for _, user := range users { + bot, _, err := c.ConvertUserToBot(user.Id) + if err != nil { + printer.PrintError(err.Error()) + continue + } + + printer.PrintT("{{.Username}} converted to bot.", bot) + } + return nil +} + +func convertBotToUser(c client.Client, cmd *cobra.Command, userArgs []string) error { + user, err := getUserFromArg(c, userArgs[0]) + if err != nil { + return err + } + + password, _ := cmd.Flags().GetString("password") + if password == "" { + return errors.New("password is required") + } + + up := &model.UserPatch{Password: &password} + + username, _ := cmd.Flags().GetString("username") + if username == "" { + if user.Username == "" { + return errors.New("username is empty") + } + } else { + up.Username = model.NewString(username) + } + + email, _ := cmd.Flags().GetString("email") + if email == "" { + if user.Email == "" { + return errors.New("email is empty") + } + } else { + up.Email = model.NewString(email) + } + + nickname, _ := cmd.Flags().GetString("nickname") + if nickname != "" { + up.Nickname = model.NewString(nickname) + } + + firstname, _ := cmd.Flags().GetString("firstname") + if firstname != "" { + up.FirstName = model.NewString(firstname) + } + + lastname, _ := cmd.Flags().GetString("lastname") + if lastname != "" { + up.LastName = model.NewString(lastname) + } + + locale, _ := cmd.Flags().GetString("locale") + if locale != "" { + up.Locale = model.NewString(locale) + } + + systemAdmin, _ := cmd.Flags().GetBool("system-admin") + if !systemAdmin { + systemAdmin, _ = cmd.Flags().GetBool("system_admin") + } + + user, _, err = c.ConvertBotToUser(user.Id, up, systemAdmin) + if err != nil { + return err + } + + printer.PrintT("{{.Username}} converted to user.", user) + + return nil +} + +func migrateAuthCmdF(c client.Client, cmd *cobra.Command, userArgs []string) error { + if userArgs[1] == "saml" { + return migrateAuthToSamlCmdF(c, cmd, userArgs) + } + return migrateAuthToLdapCmdF(c, cmd, userArgs) +} + +func migrateAuthToSamlCmdF(c client.Client, cmd *cobra.Command, userArgs []string) error { + fromAuth := userArgs[0] + auto, _ := cmd.Flags().GetBool("auto") + confirm, _ := cmd.Flags().GetBool("confirm") + if auto && !confirm { + if err := getConfirmation("You are about to perform an automatic \""+fromAuth+" to saml\" migration. This must only be done if your current Mattermost users with "+fromAuth+" auth have the same username and email in your SAML service. Otherwise, provide the usernames and emails from your SAML Service using the \"users file\" without the \"--auto\" option.\n\nDo you want to proceed with automatic migration anyway?", false); err != nil { + return err + } + } + + matches := map[string]string{} + if !auto { + matchesFile := userArgs[2] + + file, err := ioutil.ReadFile(matchesFile) + if err != nil { + return fmt.Errorf("could not read file: %w", err) + } + if err := json.Unmarshal(file, &matches); err != nil { + return fmt.Errorf("invalid json: %w", err) + } + } + + if fromAuth == "" || (fromAuth != "email" && fromAuth != "gitlab" && fromAuth != "ldap" && fromAuth != "google" && fromAuth != "office365") { + return errors.New("invalid from_auth argument") + } + + resp, err := c.MigrateAuthToSaml(fromAuth, matches, auto) + if err != nil { + return err + } else if resp.StatusCode == http.StatusOK { + printer.Print("Successfully migrated accounts.") + } + + return nil +} + +func migrateAuthToLdapCmdF(c client.Client, cmd *cobra.Command, userArgs []string) error { + fromAuth := userArgs[0] + if fromAuth == "" || (fromAuth != "email" && fromAuth != "gitlab" && fromAuth != "saml" && fromAuth != "google" && fromAuth != "office365") { // nolint:goconst + return errors.New("invalid from_auth argument") + } + + matchField := userArgs[2] + if matchField == "" || (matchField != "email" && matchField != "username") { + return errors.New("invalid match_field argument") + } + + force, _ := cmd.Flags().GetBool("force") + + resp, err := c.MigrateAuthToLdap(fromAuth, matchField, force) + if err != nil { + return err + } else if resp.StatusCode == http.StatusOK { + printer.Print("Successfully migrated accounts.") + } + + return nil +} + +func promoteGuestToUserCmdF(c client.Client, _ *cobra.Command, userArgs []string) error { + for i, user := range getUsersFromUserArgs(c, userArgs) { + if user == nil { + printer.PrintError(fmt.Sprintf("can't find guest '%v'", userArgs[i])) + continue + } + + if _, err := c.PromoteGuestToUser(user.Id); err != nil { + printer.PrintError(fmt.Sprintf("unable to promote guest %s: %s", userArgs[i], err)) + continue + } + + printer.PrintT("User {{.Username}} promoted.", user) + } + + return nil +} + +func demoteUserToGuestCmdF(c client.Client, _ *cobra.Command, userArgs []string) error { + var errs *multierror.Error + for i, user := range getUsersFromUserArgs(c, userArgs) { + if user == nil { + err := fmt.Errorf("can't find user '%s'", userArgs[i]) + errs = multierror.Append(errs, err) + printer.PrintError(err.Error()) + continue + } + + if _, err := c.DemoteUserToGuest(user.Id); err != nil { + err = fmt.Errorf("unable to demote user %s: %w", userArgs[i], err) + errs = multierror.Append(errs, err) + printer.PrintError(err.Error()) + continue + } + + printer.PrintT("User {{.Username}} demoted.", user) + } + + return errs.ErrorOrNil() +} diff --git a/server/cmd/mmctl/commands/user_e2e_test.go b/server/cmd/mmctl/commands/user_e2e_test.go new file mode 100644 index 0000000000..d85c825aa2 --- /dev/null +++ b/server/cmd/mmctl/commands/user_e2e_test.go @@ -0,0 +1,1069 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlE2ETestSuite) TestUserActivateCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + s.RunForSystemAdminAndLocal("Activate user", func(c client.Client) { + printer.Clean() + + _, appErr := s.th.App.UpdateActive(s.th.Context, user, false) + s.Require().Nil(appErr) + + err := userActivateCmdF(c, &cobra.Command{}, []string{user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + ruser, err := s.th.App.GetUser(user.Id) + s.Require().Nil(err) + s.Require().Zero(ruser.DeleteAt) + }) + + s.Run("Activate user without permissions", func() { + printer.Clean() + + _, appErr := s.th.App.UpdateActive(s.th.Context, user, false) + s.Require().Nil(appErr) + + err := userActivateCmdF(s.th.Client, &cobra.Command{}, []string{user.Email}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "unable to change activation status of user: "+user.Id) + + ruser, err := s.th.App.GetUser(user.Id) + s.Require().Nil(err) + s.Require().NotZero(ruser.DeleteAt) + }) + + s.RunForAllClients("Activate nonexistent user", func(c client.Client) { + printer.Clean() + + err := userActivateCmdF(c, &cobra.Command{}, []string{"nonexistent@email"}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("1 error occurred:\n\t* user nonexistent@email not found\n\n", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestUserDeactivateCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + s.RunForSystemAdminAndLocal("Deactivate user", func(c client.Client) { + printer.Clean() + + _, appErr := s.th.App.UpdateActive(s.th.Context, user, true) + s.Require().Nil(appErr) + + err := userDeactivateCmdF(c, &cobra.Command{}, []string{user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + ruser, err := s.th.App.GetUser(user.Id) + s.Require().Nil(err) + s.Require().NotZero(ruser.DeleteAt) + }) + + s.Run("Deactivate user without permissions", func() { + printer.Clean() + + _, appErr := s.th.App.UpdateActive(s.th.Context, user, true) + s.Require().Nil(appErr) + + err := userDeactivateCmdF(s.th.Client, &cobra.Command{}, []string{user.Email}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(printer.GetErrorLines()[0], "unable to change activation status of user: "+user.Id) + + ruser, err := s.th.App.GetUser(user.Id) + s.Require().Nil(err) + s.Require().Zero(ruser.DeleteAt) + }) + + s.RunForAllClients("Deactivate nonexistent user", func(c client.Client) { + printer.Clean() + + err := userDeactivateCmdF(c, &cobra.Command{}, []string{"nonexistent@email"}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("1 error occurred:\n\t* user nonexistent@email not found\n\n", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestSearchUserCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Search for an existing user", func(c client.Client) { + printer.Clean() + + err := searchUserCmdF(c, &cobra.Command{}, []string{s.th.BasicUser.Email}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + user := printer.GetLines()[0].(*model.User) + s.Equal(s.th.BasicUser.Username, user.Username) + s.Len(printer.GetErrorLines(), 0) + }) + + s.RunForAllClients("Search for a nonexistent user", func(c client.Client) { + printer.Clean() + emailArg := "nonexistentUser@example.com" + + err := searchUserCmdF(c, &cobra.Command{}, []string{emailArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", emailArg), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestListUserCmd() { + s.SetupTestHelper().InitBasic().DeleteBots() + + // populate map for checking + userPool := []string{ + s.th.BasicUser.Username, + s.th.BasicUser2.Username, + s.th.TeamAdminUser.Username, + s.th.SystemAdminUser.Username, + s.th.SystemManagerUser.Username, + } + for i := 0; i < 10; i++ { + userData := model.User{ + Username: "fakeuser" + model.NewRandomString(10), + Password: "Pa$$word11", + Email: s.th.GenerateTestEmail(), + } + usr, err := s.th.App.CreateUser(s.th.Context, &userData) + s.Require().Nil(err) + userPool = append(userPool, usr.Username) + } + + s.RunForAllClients("Get some random user", func(c client.Client) { + printer.Clean() + + var page int + var all bool + perpage := 5 + team := "" + cmd := &cobra.Command{} + cmd.Flags().IntVar(&page, "page", page, "page") + cmd.Flags().IntVar(&perpage, "per-page", perpage, "perpage") + cmd.Flags().BoolVar(&all, "all", all, "all") + cmd.Flags().StringVar(&team, "team", team, "team") + + err := listUsersCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Require().GreaterOrEqual(len(printer.GetLines()), 5) + s.Len(printer.GetErrorLines(), 0) + + for _, u := range printer.GetLines() { + user := u.(*model.User) + s.Require().Contains(userPool, user.Username) + } + }) + + s.RunForAllClients("Get list of all user", func(c client.Client) { + printer.Clean() + + var page int + perpage := 10 + all := true + team := "" + cmd := &cobra.Command{} + cmd.Flags().IntVar(&page, "page", page, "page") + cmd.Flags().IntVar(&perpage, "per-page", perpage, "perpage") + cmd.Flags().BoolVar(&all, "all", all, "all") + cmd.Flags().StringVar(&team, "team", team, "team") + + err := listUsersCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Require().GreaterOrEqual(len(printer.GetLines()), 14) + s.Len(printer.GetErrorLines(), 0) + for _, each := range printer.GetLines() { + user := each.(*model.User) + s.Require().Contains(userPool, user.Username) + } + }) +} + +func (s *MmctlE2ETestSuite) TestUserInviteCmdf() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Invite user", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableEmailInvitations + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = true }) + defer s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = *previousVal }) + + err := userInviteCmdF(c, &cobra.Command{}, []string{s.th.BasicUser.Email, s.th.BasicTeam.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(printer.GetLines()[0], "Invites may or may not have been sent.") + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.RunForAllClients("Inviting when email invitation disabled", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableEmailInvitations + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = false }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = *previousVal }) + }() + + err := userInviteCmdF(c, &cobra.Command{}, []string{s.th.BasicUser.Email, s.th.BasicTeam.Id}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal( + fmt.Sprintf("Unable to invite user with email %s to team %s. Error: : Email invitations are disabled.", + s.th.BasicUser.Email, + s.th.BasicTeam.Name, + ), + printer.GetErrorLines()[0], + ) + }) + + s.RunForAllClients("Invite user outside of accepted domain", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableEmailInvitations + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = true }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = *previousVal }) + }() + + team := s.th.CreateTeam() + team.AllowedDomains = "@example.com" + team, appErr := s.th.App.UpdateTeam(team) + s.Require().Nil(appErr) + + user := s.th.CreateUser() + err := userInviteCmdF(c, &cobra.Command{}, []string{user.Email, team.Id}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal( + fmt.Sprintf(`Unable to invite user with email %s to team %s. Error: : The following email addresses do not belong to an accepted domain: %s. Please contact your System Administrator for details.`, + user.Email, + team.Name, + user.Email, + ), + printer.GetErrorLines()[0], + ) + }) +} + +func (s *MmctlE2ETestSuite) TestResetUserMfaCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId(), MfaActive: true, MfaSecret: "secret"}) + s.Require().Nil(appErr) + + s.RunForSystemAdminAndLocal("Reset user mfa", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableMultifactorAuthentication + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true }) + defer s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = *previousVal }) + + err := resetUserMfaCmdF(c, &cobra.Command{}, []string{user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + + // make sure user is updated after reset mfa + ruser, err := s.th.App.GetUser(user.Id) + s.Require().Nil(err) + s.Require().NotEqual(ruser.UpdateAt, user.UpdateAt) + }) + + s.RunForSystemAdminAndLocal("Reset mfa disabled config", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableMultifactorAuthentication + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = false }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = *previousVal }) + }() + + userMfaInactive, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId(), MfaActive: false}) + s.Require().Nil(appErr) + + err := resetUserMfaCmdF(c, &cobra.Command{}, []string{userMfaInactive.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Reset user mfa without permission", func() { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableMultifactorAuthentication + + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = *previousVal }) + }() + + err := resetUserMfaCmdF(s.th.Client, &cobra.Command{}, []string{user.Email}) + + var expected error + + expected = multierror.Append( + expected, fmt.Errorf(`unable to reset user %q MFA. Error: : You do not have the appropriate permissions.`, user.Id), //nolint:revive + + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestVerifyUserEmailWithoutTokenCmd() { + s.SetupTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + s.RunForSystemAdminAndLocal("Verify user email without token", func(c client.Client) { + printer.Clean() + + err := verifyUserEmailWithoutTokenCmdF(c, &cobra.Command{}, []string{user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Verify user email without token (without permission)", func() { + printer.Clean() + + err := verifyUserEmailWithoutTokenCmdF(s.th.Client, &cobra.Command{}, []string{user.Email}) + var expected error + + expected = multierror.Append( + expected, fmt.Errorf("unable to verify user "+user.Id+" email: : You do not have the appropriate permissions."), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) + + s.RunForAllClients("Verify user email without token for nonexistent user", func(c client.Client) { + printer.Clean() + + err := verifyUserEmailWithoutTokenCmdF(c, &cobra.Command{}, []string{"nonexistent@email"}) + var expected error + + expected = multierror.Append( + expected, ExtractErrorFromResponse( + &model.Response{StatusCode: http.StatusNotFound}, + ErrEntityNotFound{Type: "user", ID: "nonexistent@email"}, + ), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestCreateUserCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Should not create a user w/o username", func(c client.Client) { + printer.Clean() + email := s.th.GenerateTestEmail() + cmd := &cobra.Command{} + cmd.Flags().String("password", "somepass", "") + cmd.Flags().String("email", email, "") + + err := userCreateCmdF(c, cmd, []string{}) + s.EqualError(err, "Username is required: flag accessed but not defined: username") + s.Require().Empty(printer.GetLines()) + _, err = s.th.App.GetUserByEmail(email) + s.Require().NotNil(err) + s.Require().ErrorContains(err, "GetUserByEmail: Unable to find the user., failed to find User: resource: User id: email="+email) + }) + + s.RunForAllClients("Should not create a user w/o email", func(c client.Client) { + printer.Clean() + username := model.NewId() + cmd := &cobra.Command{} + cmd.Flags().String("username", username, "") + cmd.Flags().String("password", "somepass", "") + + err := userCreateCmdF(c, cmd, []string{}) + s.EqualError(err, "Email is required: flag accessed but not defined: email") + s.Require().Empty(printer.GetLines()) + _, err = s.th.App.GetUserByUsername(username) + s.Require().NotNil(err) + s.Require().ErrorContains(err, "GetUserByUsername: Unable to find an existing account matching your username for this team. This team may require an invite from the team owner to join., failed to find User: resource: User id: username="+username) + }) + + s.RunForAllClients("Should not create a user w/o password", func(c client.Client) { + printer.Clean() + email := s.th.GenerateTestEmail() + cmd := &cobra.Command{} + cmd.Flags().String("username", model.NewId(), "") + cmd.Flags().String("email", email, "") + + err := userCreateCmdF(c, cmd, []string{}) + s.EqualError(err, "Password is required: flag accessed but not defined: password") + s.Require().Empty(printer.GetLines()) + _, err = s.th.App.GetUserByEmail(email) + s.Require().NotNil(err) + s.Require().ErrorContains(err, "GetUserByEmail: Unable to find the user., failed to find User: resource: User id: email="+email) + }) + + s.Run("Should create a user but w/o system-admin privileges", func() { + printer.Clean() + email := s.th.GenerateTestEmail() + username := model.NewId() + cmd := &cobra.Command{} + cmd.Flags().String("username", username, "") + cmd.Flags().String("email", email, "") + cmd.Flags().String("password", "password", "") + cmd.Flags().Bool("system-admin", true, "") + + err := userCreateCmdF(s.th.Client, cmd, []string{}) + s.EqualError(err, "Unable to update user roles. Error: : You do not have the appropriate permissions.") + s.Require().Empty(printer.GetLines()) + user, err := s.th.App.GetUserByEmail(email) + s.Require().Nil(err) + s.Equal(username, user.Username) + s.Equal(false, user.IsSystemAdmin()) + }) + + s.RunForSystemAdminAndLocal("Should create new system-admin user given required params", func(c client.Client) { + printer.Clean() + email := s.th.GenerateTestEmail() + username := model.NewId() + cmd := &cobra.Command{} + cmd.Flags().String("username", username, "") + cmd.Flags().String("email", email, "") + cmd.Flags().String("password", "somepass", "") + cmd.Flags().Bool("system-admin", true, "") + + err := userCreateCmdF(s.th.SystemAdminClient, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + user, err := s.th.App.GetUserByEmail(email) + s.Require().Nil(err) + s.Equal(username, user.Username) + s.Equal(true, user.IsSystemAdmin()) + }) + + s.RunForAllClients("Should create new user given required params", func(c client.Client) { + printer.Clean() + email := s.th.GenerateTestEmail() + username := model.NewId() + cmd := &cobra.Command{} + cmd.Flags().String("username", username, "") + cmd.Flags().String("email", email, "") + cmd.Flags().String("password", "somepass", "") + + err := userCreateCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + user, err := s.th.App.GetUserByEmail(email) + s.Require().Nil(err) + s.Equal(username, user.Username) + s.Equal(false, user.IsSystemAdmin()) + }) + + s.RunForSystemAdminAndLocal("Should create new user with the email already verified only for admin or local mode", func(c client.Client) { + printer.Clean() + email := s.th.GenerateTestEmail() + username := model.NewId() + cmd := &cobra.Command{} + cmd.Flags().String("username", username, "") + cmd.Flags().String("email", email, "") + cmd.Flags().String("password", "somepass", "") + cmd.Flags().Bool("email-verified", true, "") + + err := userCreateCmdF(c, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + user, err := s.th.App.GetUserByEmail(email) + s.Require().Nil(err) + s.Equal(username, user.Username) + s.Equal(false, user.IsSystemAdmin()) + s.Equal(true, user.EmailVerified) + }) +} + +func (s *MmctlE2ETestSuite) TestUpdateUserEmailCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("admin and local user can change user email", func(c client.Client) { + printer.Clean() + oldEmail := s.th.BasicUser2.Email + newEmail := "basicuser2@fakedomain.com" + err := updateUserEmailCmdF(c, &cobra.Command{}, []string{s.th.BasicUser2.Email, newEmail}) + s.Require().Nil(err) + + u, err := s.th.App.GetUser(s.th.BasicUser2.Id) + s.Require().Nil(err) + s.Require().Equal(newEmail, u.Email) + + u.Email = oldEmail + _, err = s.th.App.UpdateUser(s.th.Context, u, false) + s.Require().Nil(err) + }) + + s.Run("normal user doesn't have permission to change another user's email", func() { + printer.Clean() + newEmail := "basicuser2-change@fakedomain.com" + err := updateUserEmailCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicUser2.Id, newEmail}) + s.Require().EqualError(err, ": You do not have the appropriate permissions.") + + u, err := s.th.App.GetUser(s.th.BasicUser2.Id) + s.Require().Nil(err) + s.Require().Equal(s.th.BasicUser2.Email, u.Email) + }) + + s.Run("normal users can't update their own email due to security reasons", func() { + printer.Clean() + + newEmail := "basicuser-change@fakedomain.com" + err := updateUserEmailCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicUser.Id, newEmail}) + s.Require().EqualError(err, ": Invalid or missing password in request body.") + }) +} + +func (s *MmctlE2ETestSuite) TestUpdateUsernameCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("admin and local user can change user name", func(c client.Client) { + printer.Clean() + oldName := s.th.BasicUser2.Username + newName := "basicusernamechange" + err := updateUsernameCmdF(c, &cobra.Command{}, []string{s.th.BasicUser2.Username, newName}) + s.Require().Nil(err) + + u, err := s.th.App.GetUser(s.th.BasicUser2.Id) + s.Require().Nil(err) + s.Require().Equal(newName, u.Username) + + u.Username = oldName + _, err = s.th.App.UpdateUser(s.th.Context, u, false) + s.Require().Nil(err) + }) + + s.Run("normal user doesn't have permission to change another user's name", func() { + printer.Clean() + newUsername := "basicusernamechange" + err := updateUsernameCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicUser2.Id, newUsername}) + s.Require().EqualError(err, ": You do not have the appropriate permissions.") + + u, err := s.th.App.GetUser(s.th.BasicUser2.Id) + s.Require().Nil(err) + s.Require().Equal(s.th.BasicUser2.Username, u.Username) + }) + + s.Run("Can't change by a invalid username", func() { + printer.Clean() + newUsername := "invalid username" + err := updateUsernameCmdF(s.th.Client, &cobra.Command{}, []string{s.th.BasicUser2.Id, newUsername}) + s.Require().EqualError(err, "invalid username: '"+newUsername+"'") + + u, err := s.th.App.GetUser(s.th.BasicUser2.Id) + s.Require().Nil(err) + s.Require().Equal(s.th.BasicUser2.Username, u.Username) + }) + + s.RunForSystemAdminAndLocal("Delete nonexistent user", func(c client.Client) { + printer.Clean() + oldName := "nonexistentuser" + newUsername := "basicusernamechange" + err := updateUsernameCmdF(s.th.Client, &cobra.Command{}, []string{oldName, newUsername}) + s.Require().EqualError(err, "unable to find user '"+oldName+"'") + }) +} + +func (s *MmctlE2ETestSuite) TestDeleteUsersCmd() { + s.SetupTestHelper().InitBasic() + + s.RunForSystemAdminAndLocal("Delete user", func(c client.Client) { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableAPIUserDeletion + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = true }) + defer s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = *previousVal }) + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + newUser := s.th.CreateUser() + err := deleteUsersCmdF(c, cmd, []string{newUser.Email}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + + deletedUser := printer.GetLines()[0].(*model.User) + s.Require().Equal(newUser.Username, deletedUser.Username) + + // expect user deleted + _, err = s.th.App.GetUser(newUser.Id) + s.Require().NotNil(err) + s.Require().Equal("GetUser: Unable to find the user., resource: User id: "+newUser.Id, err.Error()) + }) + + s.RunForSystemAdminAndLocal("Delete nonexistent user", func(c client.Client) { + printer.Clean() + emailArg := "nonexistentUser@example.com" + + previousVal := s.th.App.Config().ServiceSettings.EnableAPIUserDeletion + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = true }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = *previousVal }) + }() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + err := deleteUsersCmdF(c, cmd, []string{emailArg}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", emailArg), printer.GetErrorLines()[0]) + }) + + s.Run("Delete user without permission", func() { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableAPIUserDeletion + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = true }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = *previousVal }) + }() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + newUser := s.th.CreateUser() + err := deleteUsersCmdF(s.th.Client, cmd, []string{newUser.Email}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Unable to delete user '%s' error: : You do not have the appropriate permissions.", newUser.Username), printer.GetErrorLines()[0]) + + // expect user not deleted + user, err := s.th.App.GetUser(newUser.Id) + s.Require().Nil(err) + s.Require().Equal(newUser.Username, user.Username) + }) + + s.Run("Delete user with disabled config as system admin", func() { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableAPIUserDeletion + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = false }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = *previousVal }) + }() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + newUser := s.th.CreateUser() + err := deleteUsersCmdF(s.th.SystemAdminClient, cmd, []string{newUser.Email}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("Unable to delete user '%s' error: : Permanent user deletion feature is not enabled. Please contact your System Administrator.", newUser.Username), printer.GetErrorLines()[0]) + + // expect user not deleted + user, err := s.th.App.GetUser(newUser.Id) + s.Require().Nil(err) + s.Require().Equal(newUser.Username, user.Username) + }) + + s.Run("Delete user with disabled config as local client", func() { + printer.Clean() + + previousVal := s.th.App.Config().ServiceSettings.EnableAPIUserDeletion + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = false }) + defer func() { + s.th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPIUserDeletion = *previousVal }) + }() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + newUser := s.th.CreateUser() + err := deleteUsersCmdF(s.th.LocalClient, cmd, []string{newUser.Email}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + + deletedUser := printer.GetLines()[0].(*model.User) + s.Require().Equal(newUser.Username, deletedUser.Username) + + // expect user deleted + _, err = s.th.App.GetUser(newUser.Id) + s.Require().NotNil(err) + s.Require().EqualError(err, "GetUser: Unable to find the user., resource: User id: "+newUser.Id) + }) +} + +func (s *MmctlE2ETestSuite) TestUserConvertCmdF() { + s.SetupTestHelper().InitBasic() + + s.RunForAllClients("Error when no flag provided", func(c client.Client) { + printer.Clean() + + emailArg := "example@example.com" + cmd := &cobra.Command{} + + err := userConvertCmdF(c, cmd, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Equal("either \"user\" flag or \"bot\" flag should be provided", err.Error()) + }) + + s.RunForAllClients("Error for invalid user", func(c client.Client) { + printer.Clean() + + emailArg := "something@something.com" + cmd := &cobra.Command{} + cmd.Flags().Bool("bot", true, "") + + _ = userConvertCmdF(c, cmd, []string{emailArg}) + s.Require().Len(printer.GetLines(), 0) + }) + + s.RunForSystemAdminAndLocal("Valid user to bot convert", func(c client.Client) { + printer.Clean() + + user, _ := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + + email := user.Email + cmd := &cobra.Command{} + cmd.Flags().Bool("bot", true, "") + + err := userConvertCmdF(c, cmd, []string{email}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + bot := printer.GetLines()[0].(*model.Bot) + s.Equal(user.Username, bot.Username) + s.Equal(user.Id, bot.UserId) + s.Equal(user.Id, bot.OwnerId) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Permission error for valid user to bot convert", func() { + printer.Clean() + + email := s.th.BasicUser2.Email + cmd := &cobra.Command{} + cmd.Flags().Bool("bot", true, "") + + _ = userConvertCmdF(s.th.Client, cmd, []string{email}) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Equal(": You do not have the appropriate permissions.", printer.GetErrorLines()[0]) + }) + + s.RunForSystemAdminAndLocal("Valid bot to user convert", func(c client.Client) { + printer.Clean() + + username := "fakeuser" + model.NewRandomString(10) + bot, _ := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: username, DisplayName: username, OwnerId: username}) + + cmd := &cobra.Command{} + cmd.Flags().Bool("user", true, "") + cmd.Flags().String("password", "password", "") + + err := userConvertCmdF(c, cmd, []string{bot.Username}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + user := printer.GetLines()[0].(*model.User) + s.Equal(user.Username, bot.Username) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Permission error for valid bot to user convert", func() { + printer.Clean() + + username := "fakeuser" + model.NewRandomString(10) + bot, _ := s.th.App.CreateBot(s.th.Context, &model.Bot{Username: username, DisplayName: username, OwnerId: username}) + + cmd := &cobra.Command{} + cmd.Flags().Bool("user", true, "") + cmd.Flags().String("password", "password", "") + + err := userConvertCmdF(s.th.Client, cmd, []string{bot.Username}) + s.Require().Error(err) + s.EqualError(err, ": You do not have the appropriate permissions.") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlE2ETestSuite) TestDeleteAllUserCmd() { + s.SetupTestHelper().InitBasic() + + s.Run("Delete all user as unpriviliged user should not work", func() { + printer.Clean() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + err := deleteAllUsersCmdF(s.th.Client, cmd, []string{}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + + // expect users not deleted + users, err := s.th.App.GetUsersPage(&model.UserGetOptions{ + Page: 0, + PerPage: 10, + }, true) + s.Require().Nil(err) + s.Require().NotZero(len(users)) + }) + + s.Run("Delete all user as system admin through the port API should not work", func() { + printer.Clean() + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + err := deleteAllUsersCmdF(s.th.SystemAdminClient, cmd, []string{}) + s.Require().NotNil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + + // expect users not deleted + users, err := s.th.App.GetUsersPage(&model.UserGetOptions{ + Page: 0, + PerPage: 10, + }, true) + s.Require().Nil(err) + s.Require().NotZero(len(users)) + }) + + s.Run("Delete all users through local mode should work correctly", func() { + printer.Clean() + + // populate with some user + for i := 0; i < 10; i++ { + userData := model.User{ + Username: "fakeuser" + model.NewRandomString(10), + Password: "Pa$$word11", + Email: s.th.GenerateTestEmail(), + } + _, err := s.th.App.CreateUser(s.th.Context, &userData) + s.Require().Nil(err) + } + + cmd := &cobra.Command{} + confirm := true + cmd.Flags().BoolVar(&confirm, "confirm", confirm, "confirm") + + // delete all users only works on local mode + err := deleteAllUsersCmdF(s.th.LocalClient, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(printer.GetLines()[0], "All users successfully deleted") + + // expect users deleted + users, err := s.th.App.GetUsersPage(&model.UserGetOptions{ + Page: 0, + PerPage: 10, + }, true) + s.Require().Nil(err) + s.Require().Zero(len(users)) + }) +} + +func (s *MmctlE2ETestSuite) TestPromoteGuestToUserCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + s.th.App.UpdateConfig(func(c *model.Config) { *c.GuestAccountsSettings.Enable = true }) + defer s.th.App.UpdateConfig(func(c *model.Config) { *c.GuestAccountsSettings.Enable = false }) + + s.Require().Nil(s.th.App.DemoteUserToGuest(s.th.Context, user)) + + s.RunForSystemAdminAndLocal("MM-T3936 Promote a guest to a user", func(c client.Client) { + printer.Clean() + + err := promoteGuestToUserCmdF(c, nil, []string{user.Email}) + s.Require().Nil(err) + defer s.Require().Nil(s.th.App.DemoteUserToGuest(s.th.Context, user)) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("MM-T3937 Promote a guest to a user with normal client", func() { + printer.Clean() + + err := promoteGuestToUserCmdF(s.th.Client, nil, []string{user.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to promote guest %s: %s", user.Email, ": You do not have the appropriate permissions."), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestDemoteUserToGuestCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + + user, appErr := s.th.App.CreateUser(s.th.Context, &model.User{Email: s.th.GenerateTestEmail(), Username: model.NewId(), Password: model.NewId()}) + s.Require().Nil(appErr) + + s.th.App.UpdateConfig(func(c *model.Config) { *c.GuestAccountsSettings.Enable = true }) + defer s.th.App.UpdateConfig(func(c *model.Config) { *c.GuestAccountsSettings.Enable = false }) + + s.RunForSystemAdminAndLocal("MM-T3938 Demote a user to a guest", func(c client.Client) { + printer.Clean() + + err := demoteUserToGuestCmdF(c, nil, []string{user.Email}) + s.Require().Nil(err) + defer s.Require().Nil(s.th.App.PromoteGuestToUser(s.th.Context, user, "")) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("MM-T3939 Demote a user to a guest with normal client", func() { + printer.Clean() + + err := demoteUserToGuestCmdF(s.th.Client, nil, []string{user.Email}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to demote user %s: %s", user.Email, ": You do not have the appropriate permissions."), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlE2ETestSuite) TestMigrateAuthCmd() { + s.SetupEnterpriseTestHelper().InitBasic() + configForLdap(s.th) + + s.Require().NoError(s.th.App.Srv().Jobs.StartWorkers()) // we need to start workers do actual sync + + ldapUser, appErr := s.th.App.CreateUser(s.th.Context, &model.User{ + Email: s.th.GenerateTestEmail(), + Username: model.NewId(), + AuthData: model.NewString("test.user.1"), + AuthService: model.UserAuthServiceLdap, + }) + s.Require().Nil(appErr) + + samlUser, appErr := s.th.App.CreateUser(s.th.Context, &model.User{ + Email: "success+devone@simulator.amazonses.com", + Username: "dev.one", + AuthData: model.NewString("dev.one"), + AuthService: model.UserAuthServiceSaml, + }) + s.Require().Nil(appErr) + + s.Run("Should fail when regular user tries to migrate auth", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", true, "") + cmd.Flags().Bool("confirm", true, "") + + err := migrateAuthCmdF(s.th.Client, cmd, []string{"ldap", "saml"}) + s.Require().Error(err) + s.Require().Empty(printer.GetLines()) + s.Require().Empty(printer.GetErrorLines()) + }) + + s.RunForSystemAdminAndLocal("Migrate from ldap to saml", func(c client.Client) { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", true, "") + cmd.Flags().Bool("confirm", true, "") + + err := migrateAuthCmdF(c, cmd, []string{"ldap", "saml"}) + s.Require().NoError(err) + defer func() { + _, appErr := s.th.App.UpdateUserAuth(ldapUser.Id, &model.UserAuth{ + AuthData: model.NewString("test.user.1"), + AuthService: model.UserAuthServiceLdap, + }) + s.Require().Nil(appErr) + + newUser, appErr := s.th.App.UpdateUser(s.th.Context, ldapUser, false) + s.Require().Nil(appErr) + s.Require().Equal(model.UserAuthServiceLdap, newUser.AuthService) + }() + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("Successfully migrated accounts.", printer.GetLines()[0]) + s.Require().Empty(printer.GetErrorLines()) + + updatedUser, appErr := s.th.App.GetUser(ldapUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.UserAuthServiceSaml, updatedUser.AuthService) + }) + + s.RunForSystemAdminAndLocal("Migrate from saml to ldap", func(c client.Client) { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + cmd.Flags().Bool("force", true, "") + + err := migrateAuthCmdF(c, cmd, []string{"saml", "ldap", "email"}) + s.Require().NoError(err) + defer func() { + _, appErr := s.th.App.UpdateUserAuth(samlUser.Id, &model.UserAuth{ + AuthData: model.NewString("dev.one"), + AuthService: model.UserAuthServiceSaml, + }) + s.Require().Nil(appErr) + + newUser, appErr := s.th.App.UpdateUser(s.th.Context, samlUser, false) + s.Require().Nil(appErr) + s.Require().Equal(model.UserAuthServiceSaml, newUser.AuthService) + }() + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("Successfully migrated accounts.", printer.GetLines()[0]) + s.Require().Empty(printer.GetErrorLines()) + + updatedUser, appErr := s.th.App.GetUser(samlUser.Id) + s.Require().Nil(appErr) + s.Require().Equal(model.UserAuthServiceLdap, updatedUser.AuthService) + }) +} diff --git a/server/cmd/mmctl/commands/user_test.go b/server/cmd/mmctl/commands/user_test.go new file mode 100644 index 0000000000..c589abdd27 --- /dev/null +++ b/server/cmd/mmctl/commands/user_test.go @@ -0,0 +1,2736 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strconv" + "strings" + + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestUserActivateCmd() { + s.Run("Activate user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userActivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to activate unexistent user", func() { + printer.Clean() + emailArg := "example@example.com" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + err := userActivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", emailArg), printer.GetErrorLines()[0]) + }) + + s.Run("Fail to activate user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, true). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := userActivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Errorf("unable to change activation status of user: %v", mockUser.Id).Error(), printer.GetErrorLines()[0]) + }) + + s.Run("Activate several users with unexistent ones and failed ones", func() { + printer.Clean() + emailArgs := []string{"example0@example0.com", "null", "example2@example2.com", "failure@failure.com", "example4@example4.com"} + mockUser0 := model.User{Id: "example0", Username: "ExampleUser0", Email: emailArgs[0]} + mockUser2 := model.User{Id: "example2", AuthService: "other", Username: "ExampleUser2", Email: emailArgs[2]} + mockUser3 := model.User{Id: "failure", Username: "FailureUser", Email: emailArgs[3]} + mockUser4 := model.User{Id: "example4", Username: "ExampleUser4", Email: emailArgs[4]} + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[0], ""). + Return(&mockUser0, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[2], ""). + Return(&mockUser2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[3], ""). + Return(&mockUser3, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[4], ""). + Return(&mockUser4, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser0.Id, true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser2.Id, true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser3.Id, true). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser4.Id, true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userActivateCmdF(s.client, &cobra.Command{}, emailArgs) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", emailArgs[1]), printer.GetErrorLines()[0]) + s.Require().Equal(fmt.Sprintf("unable to change activation status of user: %v", mockUser3.Id), printer.GetErrorLines()[1]) + }) +} + +func (s *MmctlUnitTestSuite) TestDeactivateUserCmd() { + s.Run("Deactivate user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Try to deactivate unexistent user", func() { + printer.Clean() + emailArg := "example@example.com" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %v not found\n\n", emailArg), printer.GetErrorLines()[0]) + }) + + s.Run("Fail to deactivate user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Errorf("unable to change activation status of user: %v", mockUser.Id).Error(), printer.GetErrorLines()[0]) + }) + + s.Run("Deactivate SSO user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", AuthService: "other", Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("You must also deactivate user "+mockUser.Id+" in the SSO provider or they will be reactivated on next login or sync.", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Deactivate several users with unexistent ones, SSO ones and failed ones", func() { + printer.Clean() + emailArgs := []string{"example0@example0.com", "null", "example2@example2.com", "failure@failure.com", "example4@example4.com"} + mockUser0 := model.User{Id: "example0", Username: "ExampleUser0", Email: emailArgs[0]} + mockUser2 := model.User{Id: "example2", AuthService: "other", Username: "ExampleUser2", Email: emailArgs[2]} + mockUser3 := model.User{Id: "failure", Username: "FailureUser", Email: emailArgs[3]} + mockUser4 := model.User{Id: "example4", Username: "ExampleUser4", Email: emailArgs[4]} + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[0], ""). + Return(&mockUser0, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUser(emailArgs[1], ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[2], ""). + Return(&mockUser2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[3], ""). + Return(&mockUser3, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailArgs[4], ""). + Return(&mockUser4, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser0.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser2.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser3.Id, false). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser4.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, emailArgs) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("You must also deactivate user "+mockUser2.Id+" in the SSO provider or they will be reactivated on next login or sync.", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %v not found\n\n", emailArgs[1]), printer.GetErrorLines()[0]) + s.Require().Equal(fmt.Errorf("unable to change activation status of user: %v", mockUser3.Id).Error(), printer.GetErrorLines()[1]) + }) +} + +func (s *MmctlUnitTestSuite) TestDeleteUsersCmd() { + email1 := "user1@example.com" + email2 := "user2@example.com" + userID1 := model.NewId() + userID2 := model.NewId() + mockUser1 := model.User{Username: "User1", Email: email1, Id: userID1} + mockUser2 := model.User{Username: "User2", Email: email2, Id: userID2} + + s.Run("Delete users with confirm false returns an error", func() { + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", false, "") + err := deleteUsersCmdF(s.client, cmd, []string{"some"}) + s.Require().NotNil(err) + s.Require().Equal("could not proceed, either enable --confirm flag or use an interactive shell to complete operation: this is not an interactive shell", err.Error()) + }) + + s.Run("Delete user that does not exist in db returns an error", func() { + printer.Clean() + arg := "userdoesnotexist@example.com" + + s.client. + EXPECT(). + GetUserByEmail(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + err := deleteUsersCmdF(s.client, cmd, []string{arg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", arg), printer.GetErrorLines()[0]) + }) + + s.Run("Delete users should delete users", func() { + printer.Clean() + + s.client. + EXPECT(). + GetUserByEmail(email1, ""). + Return(&mockUser1, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PermanentDeleteUser(userID1). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(email2, ""). + Return(&mockUser2, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PermanentDeleteUser(userID2). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteUsersCmdF(s.client, cmd, []string{email1, email2}) + s.Require().Nil(err) + s.Require().Equal(&mockUser1, printer.GetLines()[0]) + s.Require().Equal(&mockUser2, printer.GetLines()[1]) + }) + + s.Run("Delete users with error on PermanentDeleteUser returns an error", func() { + printer.Clean() + + mockError := errors.New("an error occurred on deleting a user") + + s.client. + EXPECT(). + GetUserByEmail(email1, ""). + Return(&mockUser1, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteUser(userID1). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteUsersCmdF(s.client, cmd, []string{email1}) + s.Require().Nil(err) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to delete user 'User1' error: an error occurred on deleting a user", + printer.GetErrorLines()[0]) + }) + + s.Run("Delete two users, first fails with error other passes", func() { + printer.Clean() + + mockError := errors.New("an error occurred on deleting a user") + + s.client. + EXPECT(). + GetUserByEmail(email1, ""). + Return(&mockUser1, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByEmail(email2, ""). + Return(&mockUser2, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PermanentDeleteUser(userID1). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + s.client. + EXPECT(). + PermanentDeleteUser(userID2). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteUsersCmdF(s.client, cmd, []string{email1, email2}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(&mockUser2, printer.GetLines()[0]) + s.Require().Equal("Unable to delete user 'User1' error: an error occurred on deleting a user", + printer.GetErrorLines()[0]) + }) + + s.Run("partial delete of user, i.e failing to delete profile image gives a warning on the console.", func() { + printer.Clean() + + s.client. + EXPECT(). + GetUserByEmail(email1, ""). + Return(&mockUser1, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + PermanentDeleteUser(userID1). + Return(&model.Response{StatusCode: http.StatusAccepted}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + err := deleteUsersCmdF(s.client, cmd, []string{email1}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("There were issues with deleting profile image of the user. Please delete it manually. Id: %s", mockUser1.Id), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestDeleteAllUsersCmd() { + s.Run("Delete all users", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + s.client. + EXPECT(). + PermanentDeleteAllUsers(). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := deleteAllUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + s.Require().Equal(printer.GetLines()[0], "All users successfully deleted") + }) + + s.Run("Delete all users call fails", func() { + printer.Clean() + cmd := &cobra.Command{} + cmd.Flags().Bool("confirm", true, "") + + s.client. + EXPECT(). + PermanentDeleteAllUsers(). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("mock error")). + Times(1) + + err := deleteAllUsersCmdF(s.client, cmd, []string{}) + s.Require().NotNil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestSearchUserCmd() { + s.Run("Search for an existing user", func() { + emailArg := "example@example.com" + mockUser := model.User{Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err := searchUserCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().Nil(err) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Search for a nonexistent user", func() { + printer.Clean() + arg := "example@example.com" + + s.client. + EXPECT(). + GetUserByEmail(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := searchUserCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Equal("1 error occurred:\n\t* user example@example.com not found\n\n", printer.GetErrorLines()[0]) + }) + + s.Run("Avoid path traversal", func() { + printer.Clean() + arg := "test/../hello?@mattermost.com" + + err := searchUserCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Nil(err) + s.Require().Equal("1 error occurred:\n\t* user test/../hello?@mattermost.com not found\n\n", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestChangePasswordUserCmdF() { + s.Run("Change password for oneself", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + currentPassword := "current-password" + password := "password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserPassword(mockUser.Id, currentPassword, password). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", password, "") + cmd.Flags().String("current", currentPassword, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Change password for another user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + password := "password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserPassword(mockUser.Id, "", password). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", password, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error when changing password for oneself", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + mockError := errors.New("mock error") + currentPassword := "current-password" + password := "password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserPassword(mockUser.Id, currentPassword, password). + Return(&model.Response{StatusCode: http.StatusOK}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", password, "") + cmd.Flags().String("current", currentPassword, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().Error(err) + s.Require().EqualError(err, "changing user password failed: mock error") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error when changing password for another user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + mockError := errors.New("mock error") + password := "password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserPassword(mockUser.Id, "", password). + Return(&model.Response{StatusCode: http.StatusOK}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", password, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().Error(err) + s.Require().EqualError(err, "changing user password failed: mock error") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error changing password for a nonexisting user", func() { + printer.Clean() + arg := "example@example.com" + password := "password" + + s.client. + EXPECT(). + GetUserByEmail(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", password, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{arg}) + s.Require().Error(err) + s.Require().EqualError(err, "user example@example.com not found") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Change password by a hashed one", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + hashedPassword := "hashed-password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserHashedPassword(mockUser.Id, hashedPassword). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", hashedPassword, "") + cmd.Flags().Bool("hashed", true, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Error when changing password by a hashed one", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "userId", Username: "ExampleUser", Email: emailArg} + mockError := errors.New("mock error") + hashedPassword := "hashed-password" + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserHashedPassword(mockUser.Id, hashedPassword). + Return(&model.Response{StatusCode: http.StatusOK}, mockError). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().String("password", hashedPassword, "") + cmd.Flags().Bool("hashed", true, "") + + err := changePasswordUserCmdF(s.client, cmd, []string{emailArg}) + s.Require().EqualError(err, "changing user hashed password failed: mock error") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestSendPasswordResetEmailCmd() { + s.Run("Send one reset email", func() { + printer.Clean() + emailArg := "example@example.com" + + s.client. + EXPECT(). + SendPasswordResetEmail(emailArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := sendPasswordResetEmailCmdF(s.client, &cobra.Command{}, []string{emailArg}) + + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Send one reset email and receive error on email validation", func() { + printer.Clean() + emailArg := "invalid.Email@example.com" + + var expected error + expected = multierror.Append(expected, fmt.Errorf("invalid email '%s'", emailArg)) + + err := sendPasswordResetEmailCmdF(s.client, &cobra.Command{}, []string{emailArg}) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Invalid email '"+emailArg+"'", printer.GetErrorLines()[0]) + }) + + s.Run("Send one reset email and receive error on email sending", func() { + printer.Clean() + emailArg := "example@example.com" + mockError := errors.New("mock error") + + s.client. + EXPECT(). + SendPasswordResetEmail(emailArg). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + var expected error + expected = multierror.Append(expected, fmt.Errorf("unable send reset password email to email %s: %w", emailArg, mockError)) + + err := sendPasswordResetEmailCmdF(s.client, &cobra.Command{}, []string{emailArg}) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable send reset password email to email "+emailArg+". Error: "+mockError.Error(), printer.GetErrorLines()[0]) + }) + + s.Run("Send several reset emails and receive some errors", func() { + printer.Clean() + emailArg := []string{ + "example1@example.com", + "error1@example.com", + "invalid.Email@example.com", + "example2@example.com", + "example3@example.com"} + mockError := errors.New("mock error") + + var expected error + + for _, email := range emailArg { + switch { + case strings.HasPrefix(email, "error"): + s.client. + EXPECT(). + SendPasswordResetEmail(email). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + expected = multierror.Append(expected, fmt.Errorf("unable send reset password email to email %s: %w", email, mockError)) + case strings.ToLower(email) != email: + expected = multierror.Append(expected, fmt.Errorf("invalid email '%s'", email)) + default: + s.client. + EXPECT(). + SendPasswordResetEmail(email). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + } + } + + err := sendPasswordResetEmailCmdF(s.client, &cobra.Command{}, emailArg) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal("Unable send reset password email to email "+emailArg[1]+". Error: "+mockError.Error(), printer.GetErrorLines()[0]) + s.Require().Equal("Invalid email '"+emailArg[2]+"'", printer.GetErrorLines()[1]) + }) +} + +func (s *MmctlUnitTestSuite) TestUserInviteCmd() { + s.Run("Invite user to an existing team by Id", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := "teamId" + + s.client. + EXPECT(). + GetTeam(argTeam, ""). + Return(&model.Team{Id: argTeam}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(argTeam, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := userInviteCmdF(s.client, &cobra.Command{}, []string{argUser, argTeam}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("Invites may or may not have been sent.", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Invite user to an existing team by name", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := "teamName" + resultID := "teamId" + + s.client. + EXPECT(). + GetTeam(argTeam, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(argTeam, ""). + Return(&model.Team{Id: resultID}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(resultID, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := userInviteCmdF(s.client, &cobra.Command{}, []string{argUser, argTeam}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("Invites may or may not have been sent.", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Invite user to several existing teams by name and id", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := []string{"teamName1", "teamId2", "teamId3", "teamName4"} + resultTeamModels := [4]*model.Team{ + {Id: "teamId1"}, + {Id: "teamId2"}, + {Id: "teamId3"}, + {Id: "teamId4"}, + } + + // Setup GetTeam + s.client. + EXPECT(). + GetTeam(argTeam[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[1], ""). + Return(resultTeamModels[1], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[2], ""). + Return(resultTeamModels[2], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[3], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + // Setup GetTeamByName + s.client. + EXPECT(). + GetTeamByName(argTeam[0], ""). + Return(resultTeamModels[0], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(argTeam[3], ""). + Return(resultTeamModels[3], &model.Response{}, nil). + Times(1) + + // Setup InviteUsersToTeam + for _, resultTeamModel := range resultTeamModels { + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModel.Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + } + + err := userInviteCmdF(s.client, &cobra.Command{}, append([]string{argUser}, argTeam...)) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), len(argTeam)) + for i := 0; i < len(argTeam); i++ { + s.Require().Equal("Invites may or may not have been sent.", printer.GetLines()[i]) + } + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Invite user to an un-existing team", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := "unexistent" + + s.client. + EXPECT(). + GetTeam(argTeam, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(argTeam, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := userInviteCmdF(s.client, &cobra.Command{}, []string{argUser, argTeam}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("can't find team '"+argTeam+"'", printer.GetErrorLines()[0]) + }) + + s.Run("Invite user to an existing team and fail invite", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := "teamId" + resultName := "teamName" + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(argTeam, ""). + Return(&model.Team{Id: argTeam, Name: resultName}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(argTeam, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := userInviteCmdF(s.client, &cobra.Command{}, []string{argUser, argTeam}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to invite user with email "+argUser+" to team "+resultName+". Error: "+mockError.Error(), printer.GetErrorLines()[0]) + }) + + s.Run("Invite user to several existing and non-existing teams by name and id and reject one invite", func() { + printer.Clean() + argUser := "example@example.com" + argTeam := []string{"teamName1", "unexistent", "teamId3", "teamName4", "reject", "teamId6"} + resultTeamModels := [6]*model.Team{ + {Id: "teamId1", Name: "teamName1"}, + nil, + {Id: "teamId3", Name: "teamName3"}, + {Id: "teamId4", Name: "teamName4"}, + {Id: "reject", Name: "rejectName"}, + {Id: "teamId6", Name: "teamName6"}, + } + mockError := model.NewAppError("", "mock error", nil, "", 0) + + // Setup GetTeam + s.client. + EXPECT(). + GetTeam(argTeam[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[1], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[2], ""). + Return(resultTeamModels[2], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[3], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[4], ""). + Return(resultTeamModels[4], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeam(argTeam[5], ""). + Return(resultTeamModels[5], &model.Response{}, nil). + Times(1) + + // Setup GetTeamByName + s.client. + EXPECT(). + GetTeamByName(argTeam[0], ""). + Return(resultTeamModels[0], &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(argTeam[1], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetTeamByName(argTeam[3], ""). + Return(resultTeamModels[3], &model.Response{}, nil). + Times(1) + + // Setup InviteUsersToTeam + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModels[0].Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModels[2].Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModels[3].Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModels[4].Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + s.client. + EXPECT(). + InviteUsersToTeam(resultTeamModels[5].Id, []string{argUser}). + Return(&model.Response{StatusCode: http.StatusBadRequest}, nil). + Times(1) + + err := userInviteCmdF(s.client, &cobra.Command{}, append([]string{argUser}, argTeam...)) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 4) + for i := 0; i < 4; i++ { + s.Require().Equal("Invites may or may not have been sent.", printer.GetLines()[i]) + } + s.Require().Len(printer.GetErrorLines(), 2) + s.Require().Equal("can't find team '"+argTeam[1]+"'", printer.GetErrorLines()[0]) + s.Require().Equal("Unable to invite user with email "+argUser+" to team "+resultTeamModels[4].Name+". Error: "+mockError.Error(), printer.GetErrorLines()[1]) + }) +} + +func (s *MmctlUnitTestSuite) TestUserCreateCmd() { + mockUser := model.User{ + Username: "username", + Password: "password", + Email: "email", + } + + s.Run("Create user with email missing", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("password", mockUser.Password, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Equal("Email is required: flag accessed but not defined: email", error.Error()) + }) + + s.Run("Create user with username missing", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Equal("Username is required: flag accessed but not defined: username", error.Error()) + }) + + s.Run("Create user with password missing", func() { + printer.Clean() + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Equal("Password is required: flag accessed but not defined: password", error.Error()) + }) + + s.Run("Create a regular user", func() { + printer.Clean() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Nil(error) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a regular user with welcome email disabled", func() { + printer.Clean() + + oldDisableWelcomeEmail := mockUser.DisableWelcomeEmail + mockUser.DisableWelcomeEmail = true + defer func() { mockUser.DisableWelcomeEmail = oldDisableWelcomeEmail }() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + command.Flags().Bool("disable-welcome-email", mockUser.DisableWelcomeEmail, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Nil(error) + printerLines := printer.GetLines()[0] + printedUser := printerLines.(*model.User) + + s.Require().Equal(&mockUser, printerLines) + s.Require().Equal(true, printedUser.DisableWelcomeEmail) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a regular user with client returning error", func() { + printer.Clean() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, errors.New("remote error")). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Equal("Unable to create user. Error: remote error", error.Error()) + }) + + s.Run("Create a sysAdmin user", func() { + printer.Clean() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, "system_user system_admin"). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + command.Flags().Bool("system-admin", true, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Nil(error) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a guest user", func() { + printer.Clean() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DemoteUserToGuest(mockUser.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + command.Flags().Bool("guest", true, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Nil(error) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Create a sysAdmin user with client returning error", func() { + printer.Clean() + + s.client. + EXPECT(). + CreateUser(&mockUser). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserRoles(mockUser.Id, "system_user system_admin"). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("remote error")). + Times(1) + + command := cobra.Command{} + command.Flags().String("username", mockUser.Username, "") + command.Flags().String("email", mockUser.Email, "") + command.Flags().String("password", mockUser.Password, "") + command.Flags().Bool("system-admin", true, "") + + error := userCreateCmdF(s.client, &command, []string{}) + + s.Require().Equal("Unable to update user roles. Error: remote error", error.Error()) + }) +} + +func (s *MmctlUnitTestSuite) TestUpdateUserEmailCmd() { + s.Run("Two arguments are not provided", func() { + printer.Clean() + + command := cobra.Command{} + + error := updateUserEmailCmdF(s.client, &command, []string{}) + + s.Require().EqualError(error, "expected two arguments. See help text for details") + }) + + s.Run("Invalid email provided", func() { + printer.Clean() + + userArg := "testUser" + emailArg := "invalidEmail" + command := cobra.Command{} + + error := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().EqualError(error, "invalid email: 'invalidEmail'") + }) + + s.Run("User not found using email, username or id as identifier", func() { + printer.Clean() + + command := cobra.Command{} + userArg := "testUser" + emailArg := "example@example.com" + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given id")). + Times(1) + + err := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().EqualError(err, "user testUser not found") + }) + + s.Run("Client returning error while updating user", func() { + printer.Clean() + + command := cobra.Command{} + userArg := "testUser" + emailArg := "example@example.com" + + currentUser := model.User{Username: "testUser", Password: "password", Email: "email"} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(¤tUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUser(¤tUser). + Return(nil, &model.Response{}, errors.New("remote error")). + Times(1) + + err := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().EqualError(err, "remote error") + }) + + s.Run("User email is updated successfully using username as identifier", func() { + printer.Clean() + + command := cobra.Command{} + userArg := "testUser" + emailArg := "example@example.com" + + currentUser := model.User{Username: "testUser", Password: "password", Email: "email"} + updatedUser := model.User{Username: "testUser", Password: "password", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(¤tUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUser(¤tUser). + Return(&updatedUser, &model.Response{}, nil). + Times(1) + + err := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().Nil(err) + s.Require().Equal(&updatedUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("User email is updated successfully using email as identifier", func() { + printer.Clean() + + command := cobra.Command{} + userArg := "user@email.com" + emailArg := "example@example.com" + + currentUser := model.User{Username: "testUser", Password: "password", Email: "email"} + updatedUser := model.User{Username: "testUser", Password: "password", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(¤tUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUser(¤tUser). + Return(&updatedUser, &model.Response{}, nil). + Times(1) + + error := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().Nil(error) + s.Require().Equal(&updatedUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("User email is updated successfully using id as identifier", func() { + printer.Clean() + + command := cobra.Command{} + userArg := "userId" + emailArg := "example@example.com" + + currentUser := model.User{Username: "testUser", Password: "password", Email: "email"} + updatedUser := model.User{Username: "testUser", Password: "password", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given email")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("no user found with the given username")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(¤tUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUser(¤tUser). + Return(&updatedUser, &model.Response{}, nil). + Times(1) + + err := updateUserEmailCmdF(s.client, &command, []string{userArg, emailArg}) + + s.Require().Nil(err) + s.Require().Equal(&updatedUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestResetUserMfaCmd() { + s.Run("One user without problems", func() { + printer.Clean() + + s.client. + EXPECT(). + GetUserByEmail("userId", ""). + Return(&model.User{Id: "userId"}, nil, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserMfa("userId", "", false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := resetUserMfaCmdF(s.client, &cobra.Command{}, []string{"userId"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Cannot find one user", func() { + printer.Clean() + + s.client. + EXPECT(). + GetUserByEmail("userId", ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername("userId", ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUser("userId", ""). + Return(nil, nil, nil). + Times(1) + + err := resetUserMfaCmdF(s.client, &cobra.Command{}, []string{"userId"}) + + var expected error + + expected = multierror.Append( + expected, ExtractErrorFromResponse( + &model.Response{StatusCode: http.StatusNotFound}, + ErrEntityNotFound{Type: "user", ID: "userId"}, + ), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("One user, unable to reset", func() { + printer.Clean() + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetUserByEmail("userId", ""). + Return(&model.User{Id: "userId"}, nil, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserMfa("userId", "", false). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := resetUserMfaCmdF(s.client, &cobra.Command{}, []string{"userId"}) + + var expected error + + expected = multierror.Append( + expected, fmt.Errorf("unable to reset user \"userId\" MFA. Error: "+mockError.Error()), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Several users, with unknown users and users unable to be reset", func() { + printer.Clean() + users := []string{"user0", "error1", "user2", "notfounduser", "user4"} + mockError := errors.New("mock error") + + for _, user := range users { + if user == "notfounduser" { + s.client. + EXPECT(). + GetUserByEmail(user, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(user, ""). + Return(nil, nil, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(user, ""). + Return(nil, nil, nil). + Times(1) + } else { + s.client. + EXPECT(). + GetUserByEmail(user, ""). + Return(&model.User{Id: user}, nil, nil). + Times(1) + } + } + + for _, user := range users { + if user == "error1" { + s.client. + EXPECT(). + UpdateUserMfa(user, "", false). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + } else if user != "notfounduser" { + s.client. + EXPECT(). + UpdateUserMfa(user, "", false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + } + } + + err := resetUserMfaCmdF(s.client, &cobra.Command{}, users) + + var expected *multierror.Error + + expected = multierror.Append( + expected, ExtractErrorFromResponse( + &model.Response{StatusCode: http.StatusNotFound}, + ErrEntityNotFound{Type: "user", ID: users[3]}, + ), + ) + expected = multierror.Append( + expected, fmt.Errorf("unable to reset user \""+users[1]+"\" MFA. Error: "+mockError.Error()), + ) + + s.Require().EqualError(err, expected.ErrorOrNil().Error()) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestListUserCmdF() { + cmd := &cobra.Command{} + cmd.Flags().Int("page", 0, "") + cmd.Flags().Int("per-page", 200, "") + cmd.Flags().Bool("all", false, "") + cmd.Flags().String("team", "", "") + + s.Run("Listing users with paging", func() { + printer.Clean() + + email := "example@example.com" + mockUser := model.User{Username: "ExampleUser", Email: email} + + page := 0 + perPage := 1 + showAll := false + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + + s.client. + EXPECT(). + GetUsers(page, perPage, ""). + Return([]*model.User{&mockUser}, &model.Response{}, nil). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + }) + + s.Run("Listing all the users", func() { + printer.Clean() + + email1 := "example1@example.com" + mockUser1 := model.User{Username: "ExampleUser1", Email: email1} + email2 := "example2@example.com" + mockUser2 := model.User{Username: "ExampleUser2", Email: email2} + + page := 0 + perPage := 1 + showAll := true + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + + s.client. + EXPECT(). + GetUsers(0, perPage, ""). + Return([]*model.User{&mockUser1}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsers(1, perPage, ""). + Return([]*model.User{&mockUser2}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsers(2, perPage, ""). + Return([]*model.User{}, &model.Response{}, nil). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + s.Require().Equal(&mockUser1, printer.GetLines()[0]) + s.Require().Equal(&mockUser2, printer.GetLines()[1]) + }) + + s.Run("Try to list all the users when there are no uses in store", func() { + printer.Clean() + + page := 0 + perPage := 1 + showAll := false + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + + s.client. + EXPECT(). + GetUsers(page, perPage, ""). + Return([]*model.User{}, &model.Response{}, nil). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Return an error from GetUsers call and verify that error is properly returned", func() { + printer.Clean() + + page := 0 + perPage := 1 + showAll := false + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + + mockError := errors.New("mock error") + mockErrorW := errors.Wrap(mockError, "Failed to fetch users") + + s.client. + EXPECT(). + GetUsers(page, perPage, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().NotNil(err) + s.Require().EqualError(err, mockErrorW.Error()) + }) + + s.Run("Start with page 2 where a server has total 3 pages", func() { + printer.Clean() + + email := "example@example.com" + mockUser := model.User{Username: "ExampleUser", Email: email} + + page := 2 + perPage := 1 + showAll := false + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + + s.client. + EXPECT(). + GetUsers(page, perPage, ""). + Return([]*model.User{&mockUser}, &model.Response{}, nil). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + }) + + s.Run("Listing users for given team", func() { + printer.Clean() + + email := "example@example.com" + mockUser := model.User{Username: "ExampleUser", Email: email} + resultID := "teamId" + + page := 0 + perPage := 1 + showAll := false + team := "teamName" + _ = cmd.Flags().Set("page", strconv.Itoa(page)) + _ = cmd.Flags().Set("per-page", strconv.Itoa(perPage)) + _ = cmd.Flags().Set("all", strconv.FormatBool(showAll)) + _ = cmd.Flags().Set("team", team) + + s.client. + EXPECT(). + GetTeamByName(team, ""). + Return(&model.Team{Id: resultID}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUsersInTeam(resultID, page, perPage, ""). + Return([]*model.User{&mockUser}, &model.Response{}, nil). + Times(1) + + err := listUsersCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestUserDeactivateCmd() { + s.Run("Deactivate an existing user using email", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Username: "ExampleUser", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{mockUser.Email}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + s.Run("Deactivate an existing user by username", func() { + printer.Clean() + emailArg := "example@exam.com" + usernameArg := "ExampleUser" + mockUser := model.User{Username: usernameArg, Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(usernameArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(usernameArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{mockUser.Username}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Deactivate an existing user by id", func() { + printer.Clean() + mockUser := model.User{Id: "userId1", Username: "ExampleUser", Email: "example@exam.com"} + + s.client. + EXPECT(). + GetUserByEmail(mockUser.Id, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(mockUser.Id, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(mockUser.Id, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{mockUser.Id}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Deactivate SSO user", func() { + printer.Clean() + arg := "example@example.com" + mockUser := model.User{Id: "example-user", Username: "ExampleUser", Email: arg, AuthService: "SSO"} + + s.client. + EXPECT(). + GetUserByEmail(arg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal("You must also deactivate user "+mockUser.Id+" in the SSO provider or they will be reactivated on next login or sync.", printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Deactivate nonexistent user", func() { + printer.Clean() + arg := "example@example.com" + + s.client. + EXPECT(). + GetUserByEmail(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(arg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{arg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", arg), printer.GetErrorLines()[0]) + }) + + s.Run("Delete multiple users", func() { + printer.Clean() + mockUser1 := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + mockUser2 := model.User{Id: "userId2", Email: "user2@example.com", Username: "user2"} + mockUser3 := model.User{Id: "userId3", Email: "user3@example.com", Username: "user3"} + + argEmails := []string{mockUser1.Email, mockUser2.Email, mockUser3.Email} + argUsers := []model.User{mockUser1, mockUser2, mockUser3} + + for i := 0; i < len(argEmails); i++ { + s.client. + EXPECT(). + GetUserByEmail(argEmails[i], ""). + Return(&argUsers[i], &model.Response{}, nil). + Times(1) + } + + for i := 0; i < len(argEmails); i++ { + s.client. + EXPECT(). + UpdateUserActive(argUsers[i].Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + } + + err := userDeactivateCmdF(s.client, &cobra.Command{}, argEmails) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Delete multiple users with argument mixture of emails usernames and userIds", func() { + printer.Clean() + mockUser1 := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + mockUser2 := model.User{Id: "userId2", Email: "user2@example.com", Username: "user2"} + mockUser3 := model.User{Id: "userId3", Email: "user3@example.com", Username: "user3"} + + argsDelete := []string{mockUser1.Id, mockUser2.Email, mockUser3.Username} + argUsers := []model.User{mockUser1, mockUser2, mockUser3} + + // mockUser1 + s.client. + EXPECT(). + GetUserByEmail(argsDelete[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(argsDelete[0], ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(argsDelete[0], ""). + Return(&argUsers[0], &model.Response{}, nil). + Times(1) + + // mockUser2 + s.client. + EXPECT(). + GetUserByEmail(argsDelete[1], ""). + Return(&argUsers[1], &model.Response{}, nil). + Times(1) + + // mockUser3 + s.client. + EXPECT(). + GetUserByEmail(argsDelete[2], ""). + Return(nil, &model.Response{}, nil). + Times(1) + s.client. + EXPECT(). + GetUserByUsername(argsDelete[2], ""). + Return(&argUsers[2], &model.Response{}, nil). + Times(1) + + for _, user := range argUsers { + s.client. + EXPECT(). + UpdateUserActive(user.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + } + + err := userDeactivateCmdF(s.client, &cobra.Command{}, argsDelete) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Delete multiple users with an non existent user", func() { + printer.Clean() + mockUser1 := model.User{Id: "userId1", Email: "user1@example.com", Username: "user1"} + nonexistentEmail := "example@example.com" + + // mockUser1 + s.client. + EXPECT(). + GetUserByEmail(mockUser1.Email, ""). + Return(&mockUser1, &model.Response{}, nil). + Times(1) + + // nonexistent email + s.client. + EXPECT(). + GetUserByEmail(nonexistentEmail, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(nonexistentEmail, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUser(nonexistentEmail, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateUserActive(mockUser1.Id, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := userDeactivateCmdF(s.client, &cobra.Command{}, []string{mockUser1.Email, nonexistentEmail}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", nonexistentEmail), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestVerifyUserEmailWithoutTokenCmd() { + s.Run("Verify user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + VerifyUserEmailWithoutToken(mockUser.Id). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err := verifyUserEmailWithoutTokenCmdF(s.client, &cobra.Command{}, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Couldn't find the user", func() { + printer.Clean() + userArg := "bad-user-id" + + s.client. + EXPECT(). + GetUserByEmail(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("")). + Times(1) + + s.client. + EXPECT(). + GetUser(userArg, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, errors.New("")). + Times(1) + + err := verifyUserEmailWithoutTokenCmdF(s.client, &cobra.Command{}, []string{userArg}) + + var expected error + + expected = multierror.Append( + expected, ExtractErrorFromResponse( + &model.Response{StatusCode: http.StatusNotFound}, + ErrEntityNotFound{Type: "user", ID: userArg}, + ), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Could not verify user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + VerifyUserEmailWithoutToken(mockUser.Id). + Return(nil, &model.Response{}, errors.New("some-message")). + Times(1) + + err := verifyUserEmailWithoutTokenCmdF(s.client, &cobra.Command{}, []string{emailArg}) + + var expected error + + expected = multierror.Append( + expected, fmt.Errorf("unable to verify user %s email: %s", mockUser.Id, errors.New("some-message")), + ) + + s.Require().EqualError(err, expected.Error()) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestUserConvertCmd() { + s.Run("convert user to a bot", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + mockBot := model.Bot{UserId: "example"} + + cmd := &cobra.Command{} + cmd.Flags().Bool("bot", true, "") + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ConvertUserToBot(mockUser.Id). + Return(&mockBot, &model.Response{}, nil). + Times(1) + + err := userConvertCmdF(s.client, cmd, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockBot, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("convert bot to a user", func() { + printer.Clean() + userNameArg := "example-bot" + mockUser := model.User{Id: "example", Email: "example@example.com"} + mockBot := model.Bot{UserId: "example"} + mockBotUser := model.User{Id: "example", Username: userNameArg, IsBot: true} + + userPatch := model.UserPatch{ + Email: model.NewString("example@example.com"), + Password: model.NewString("password"), + Username: model.NewString("example-user"), + } + + cmd := &cobra.Command{} + cmd.Flags().Bool("user", true, "") + cmd.Flags().String("password", "password", "") + cmd.Flags().String("email", "example@example.com", "") + cmd.Flags().String("username", "example-user", "") + + s.client. + EXPECT(). + GetUserByEmail(userNameArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userNameArg, ""). + Return(&mockBotUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ConvertBotToUser(mockBot.UserId, &userPatch, false). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + err := userConvertCmdF(s.client, cmd, []string{userNameArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("fail for not providing either user or bot flag", func() { + printer.Clean() + emailArg := "example@example.com" + + cmd := &cobra.Command{} + + err := userConvertCmdF(s.client, cmd, []string{emailArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("got error while converting a user to a bot", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + cmd := &cobra.Command{} + cmd.Flags().Bool("bot", true, "") + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ConvertUserToBot(mockUser.Id). + Return(nil, &model.Response{}, errors.New("some-message")). + Times(1) + + err := userConvertCmdF(s.client, cmd, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + }) + + s.Run("got error while converting a bot to a user", func() { + printer.Clean() + userNameArg := "example-bot" + mockBot := model.Bot{UserId: "example"} + mockBotUser := model.User{Id: "example", Username: userNameArg, IsBot: true} + + userPatch := model.UserPatch{ + Email: model.NewString("example@example.com"), + Password: model.NewString("password"), + Username: model.NewString("example-user"), + } + + cmd := &cobra.Command{} + cmd.Flags().Bool("user", true, "") + cmd.Flags().String("password", "password", "") + cmd.Flags().String("email", "example@example.com", "") + cmd.Flags().String("username", "example-user", "") + + s.client. + EXPECT(). + GetUserByEmail(userNameArg, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByUsername(userNameArg, ""). + Return(&mockBotUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + ConvertBotToUser(mockBot.UserId, &userPatch, false). + Return(nil, &model.Response{}, errors.New("some-message")). + Times(1) + + err := userConvertCmdF(s.client, cmd, []string{userNameArg}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestMigrateAuthCmd() { + s.Run("Successfully convert auth to LDAP", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "ldap" + matchField := "username" + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", false, "") + + s.client. + EXPECT(). + MigrateAuthToLdap(fromAuth, matchField, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, matchField}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Successfully convert auth to SAML", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "saml" + + file, err := ioutil.TempFile("", "users.json") + s.Require().NoError(err) + defer os.Remove(file.Name()) + usersFile := file.Name() + + userData := map[string]string{ + "usr1@email.com": "usr.one", + "usr2@email.com": "usr.two", + } + b, err := json.MarshalIndent(userData, "", " ") + s.Require().NoError(err) + + _, err = file.Write(b) + s.Require().NoError(err) + + err = file.Sync() + s.Require().NoError(err) + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", false, "") + + s.client. + EXPECT(). + MigrateAuthToSaml(fromAuth, userData, false). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err = migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, usersFile}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Successfully convert auth to SAML (auto)", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "saml" + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", true, "") + cmd.Flags().Bool("confirm", true, "") + + s.client. + EXPECT(). + MigrateAuthToSaml(fromAuth, map[string]string{}, true). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("Invalid from auth type", func() { + printer.Clean() + + fromAuth := "onelogin" + toAuth := "ldap" + matchField := "username" + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", true, "") + cmd.Flags().Bool("confirm", true, "") + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, matchField}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Invalid matchfiled type for migrating auth to LDAP", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "ldap" + matchField := "groups" + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", false, "") + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, matchField}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Fail on convert auth to SAML due to invalid file", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "saml" + usersFile := "./nofile.json" + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", false, "") + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, usersFile}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Failed to convert auth to LDAP from server", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "ldap" + matchField := "username" + + cmd := &cobra.Command{} + cmd.Flags().Bool("force", false, "") + + s.client. + EXPECT(). + MigrateAuthToLdap(fromAuth, matchField, false). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth, matchField}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) + + s.Run("Failed to convert auth to SAML (auto) from server", func() { + printer.Clean() + + fromAuth := "email" + toAuth := "saml" + + cmd := &cobra.Command{} + cmd.Flags().Bool("auto", true, "") + cmd.Flags().Bool("confirm", true, "") + + s.client. + EXPECT(). + MigrateAuthToSaml(fromAuth, map[string]string{}, true). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := migrateAuthCmdF(s.client, cmd, []string{fromAuth, toAuth}) + s.Require().Error(err) + s.Require().Len(printer.GetLines(), 0) + }) +} + +func (s *MmctlUnitTestSuite) TestPromoteGuestToUserCmd() { + s.Run("promote a guest to a user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PromoteGuestToUser(mockUser.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := promoteGuestToUserCmdF(s.client, nil, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("cannot promote a guest to a user", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + PromoteGuestToUser(mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := promoteGuestToUserCmdF(s.client, nil, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to promote guest %s: %s", emailArg, "some-error"), printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestDemoteUserToGuestCmd() { + s.Run("demote a user to a guest", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DemoteUserToGuest(mockUser.Id). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := demoteUserToGuestCmdF(s.client, nil, []string{emailArg}) + s.Require().NoError(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Equal(&mockUser, printer.GetLines()[0]) + s.Require().Len(printer.GetErrorLines(), 0) + }) + + s.Run("cannot demote a user to a guest", func() { + printer.Clean() + emailArg := "example@example.com" + mockUser := model.User{Id: "example", Email: emailArg} + + s.client. + EXPECT(). + GetUserByEmail(emailArg, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DemoteUserToGuest(mockUser.Id). + Return(&model.Response{StatusCode: http.StatusBadRequest}, errors.New("some-error")). + Times(1) + + err := demoteUserToGuestCmdF(s.client, nil, []string{emailArg}) + s.Require().ErrorContains(err, "unable to demote user") + s.Require().Len(printer.GetLines(), 0) + s.Require().Len(printer.GetErrorLines(), 1) + s.Require().Equal(fmt.Sprintf("unable to demote user %s: %s", emailArg, "some-error"), printer.GetErrorLines()[0]) + }) +} diff --git a/server/cmd/mmctl/commands/userargs.go b/server/cmd/mmctl/commands/userargs.go new file mode 100644 index 0000000000..2e2cb6ae54 --- /dev/null +++ b/server/cmd/mmctl/commands/userargs.go @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "net/url" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" +) + +func getUsersFromUserArgs(c client.Client, userArgs []string) []*model.User { + users := make([]*model.User, 0, len(userArgs)) + for _, userArg := range userArgs { + user := getUserFromUserArg(c, userArg) + users = append(users, user) + } + return users +} + +func getUserFromUserArg(c client.Client, userArg string) *model.User { + var user *model.User + if !checkDots(userArg) { + user, _, _ = c.GetUserByEmail(userArg, "") + } + + if !checkSlash(userArg) { + if user == nil { + user, _, _ = c.GetUserByUsername(userArg, "") + } + + if user == nil { + user, _, _ = c.GetUser(userArg, "") + } + } + + return user +} + +// returns true if slash is found in the arg +func checkSlash(arg string) bool { + unescapedArg, _ := url.PathUnescape(arg) + return strings.Contains(unescapedArg, "/") +} + +// returns true if double dot is found in the arg +func checkDots(arg string) bool { + unescapedArg, _ := url.PathUnescape(arg) + return strings.Contains(unescapedArg, "..") +} + +// getUsersFromArgs obtains all the users passed by `userArgs` parameter. +// It can return users and errors at the same time +func getUsersFromArgs(c client.Client, userArgs []string) ([]*model.User, error) { + users := make([]*model.User, 0, len(userArgs)) + var result *multierror.Error + for _, userArg := range userArgs { + user, err := getUserFromArg(c, userArg) + if err != nil { + result = multierror.Append(result, err) + continue + } + users = append(users, user) + } + return users, result.ErrorOrNil() +} + +func getUserFromArg(c client.Client, userArg string) (*model.User, error) { + var user *model.User + var response *model.Response + var err error + if !checkDots(userArg) { + user, response, err = c.GetUserByEmail(userArg, "") + 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 !checkSlash(userArg) { + if user == nil { + user, response, err = c.GetUserByUsername(userArg, "") + 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 user == nil { + user, response, err = c.GetUser(userArg, "") + 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 user == nil { + return nil, ErrEntityNotFound{Type: "user", ID: userArg} + } + + return user, nil +} diff --git a/server/cmd/mmctl/commands/userargs_test.go b/server/cmd/mmctl/commands/userargs_test.go new file mode 100644 index 0000000000..3193b86469 --- /dev/null +++ b/server/cmd/mmctl/commands/userargs_test.go @@ -0,0 +1,110 @@ +// 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/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" +) + +func (s *MmctlUnitTestSuite) TestGetUserFromArgs() { + s.Run("user not found", func() { + notFoundEmail := "emailNotfound@notfound.com" + notFoundErr := errors.New("user not found") + printer.Clean() + s.client. + EXPECT(). + GetUserByEmail(notFoundEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). + Times(1) + s.client. + EXPECT(). + GetUserByUsername(notFoundEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). + Times(1) + s.client. + EXPECT(). + GetUser(notFoundEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). + Times(1) + + users, err := getUsersFromArgs(s.client, []string{notFoundEmail}) + s.Require().Empty(users) + s.Require().NotNil(err) + s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", notFoundEmail)) + }) + + s.Run("bad request don't throw unexpected error", func() { + badRequestEmail := "emailbadrequest@badrequest.com" + badRequestErr := errors.New("bad request") + printer.Clean() + s.client. + EXPECT(). + GetUserByEmail(badRequestEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). + Times(1) + s.client. + EXPECT(). + GetUserByUsername(badRequestEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). + Times(1) + s.client. + EXPECT(). + GetUser(badRequestEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). + Times(1) + + users, err := getUsersFromArgs(s.client, []string{badRequestEmail}) + s.Require().Empty(users) + s.Require().NotNil(err) + s.Require().EqualError(err, fmt.Sprintf("1 error occurred:\n\t* user %s not found\n\n", badRequestEmail)) + }) + + s.Run("unexpected error throws according error", func() { + unexpectedErrEmail := "emailunexpected@unexpected.com" + unexpectedErr := errors.New("internal server error") + printer.Clean() + s.client. + EXPECT(). + GetUserByEmail(unexpectedErrEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusInternalServerError}, unexpectedErr). + Times(1) + users, err := getUsersFromArgs(s.client, []string{unexpectedErrEmail}) + s.Require().Empty(users) + s.Require().NotNil(err) + s.Require().EqualError(err, "1 error occurred:\n\t* internal server error\n\n") + }) + s.Run("forbidden error stops searching", func() { + forbiddenErrEmail := "forbidden@forbidden.com" + forbiddenErr := errors.New("forbidden") + printer.Clean() + s.client. + EXPECT(). + GetUserByEmail(forbiddenErrEmail, ""). + Return(nil, &model.Response{StatusCode: http.StatusForbidden}, forbiddenErr). + Times(1) + users, err := getUsersFromArgs(s.client, []string{forbiddenErrEmail}) + s.Require().Empty(users) + s.Require().NotNil(err) + s.Require().EqualError(err, "1 error occurred:\n\t* forbidden\n\n") + }) + s.Run("success", func() { + successEmail := "success@success.com" + successUser := &model.User{Email: successEmail} + printer.Clean() + s.client. + EXPECT(). + GetUserByEmail(successEmail, ""). + Return(successUser, nil, nil). + Times(1) + users, err := getUsersFromArgs(s.client, []string{successEmail}) + s.Require().NoError(err) + s.Require().Len(users, 1) + s.Require().Equal(successUser, users[0]) + }) +} diff --git a/server/cmd/mmctl/commands/utils.go b/server/cmd/mmctl/commands/utils.go new file mode 100644 index 0000000000..700127b402 --- /dev/null +++ b/server/cmd/mmctl/commands/utils.go @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "archive/zip" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" +) + +func checkInteractiveTerminal() error { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return err + } + + if (fileInfo.Mode() & os.ModeCharDevice) == 0 { + return errors.New("this is not an interactive shell") + } + + return nil +} + +func zipDir(zipPath, dir string) error { + zipFile, err := os.Create(zipPath) + if err != nil { + return fmt.Errorf("cannot create file %q: %w", zipPath, err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + if err := addToZip(zipWriter, dir, "."); err != nil { + return fmt.Errorf("could not add %q to zip: %w", dir, err) + } + + return nil +} + +func addToZip(zipWriter *zip.Writer, basedir, path string) error { + dirPath := filepath.Join(basedir, path) + fileInfos, err := ioutil.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("cannot read directory %q: %w", dirPath, err) + } + + for _, fileInfo := range fileInfos { + filePath := filepath.Join(path, fileInfo.Name()) + if fileInfo.IsDir() { + filePath += "/" + } + header, err := zip.FileInfoHeader(fileInfo) + if err != nil { + return fmt.Errorf("cannot create zip file info header for %q path: %w", filePath, err) + } + header.Name = filePath + header.Method = zip.Deflate + + w, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("cannot create header for path %q: %w", filePath, err) + } + + if fileInfo.IsDir() { + if err = addToZip(zipWriter, basedir, filePath); err != nil { + return err + } + continue + } + + file, err := os.Open(filepath.Join(dirPath, fileInfo.Name())) + if err != nil { + return fmt.Errorf("cannot open file %q: %w", filePath, err) + } + + _, err = io.Copy(w, file) + file.Close() + if err != nil { + return fmt.Errorf("cannot zip file contents for file %q: %w", filePath, err) + } + } + + return nil +} + +func getPages[T any](fn func(page, numPerPage int, etag string) ([]T, *model.Response, error), perPage int) ([]T, error) { + var ( + results []T + etag string + ) + + for i := 0; ; i++ { + result, resp, err := fn(i, perPage, etag) + if err != nil { + return results, err + } + if len(result) == 0 { + break + } + + results = append(results, result...) + etag = resp.Etag + } + return results, nil +} diff --git a/server/cmd/mmctl/commands/utils_unix.go b/server/cmd/mmctl/commands/utils_unix.go new file mode 100644 index 0000000000..50868e184d --- /dev/null +++ b/server/cmd/mmctl/commands/utils_unix.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build linux || darwin +// +build linux darwin + +package commands + +import ( + "fmt" + "os" + "os/user" + "syscall" + + "github.com/isacikgoz/prompt" + "github.com/pkg/errors" +) + +func checkValidSocket(socketPath string) error { + // check file mode and permissions + fi, err := os.Stat(socketPath) + if err != nil && os.IsNotExist(err) { + return fmt.Errorf("socket file %q doesn't exists, please check the server configuration for local mode", socketPath) + } else if err != nil { + return err + } + if fi.Mode() != expectedSocketMode { + return fmt.Errorf("invalid file mode for file %q, it must be a socket with 0600 permissions", socketPath) + } + + // check matching user + cUser, err := user.Current() + if err != nil { + return err + } + s, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot get owner of the file %q", socketPath) + } + // if user id is "0", they are root and we should avoid this check + if fmt.Sprint(s.Uid) != cUser.Uid && cUser.Uid != "0" { + return fmt.Errorf("owner of the file %q must be the same user running mmctl", socketPath) + } + + return nil +} + +func getConfirmation(question string, dbConfirmation bool) error { + if err := checkInteractiveTerminal(); err != nil { + return fmt.Errorf("could not proceed, either enable --confirm flag or use an interactive shell to complete operation: %w", err) + } + + if dbConfirmation { + s, err := prompt.NewSelection("Have you performed a database backup?", []string{"no", "yes"}, "", 2) + if err != nil { + return fmt.Errorf("could not initiate prompt: %w", err) + } + ans, err := s.Run() + if err != nil { + return fmt.Errorf("error running prompt: %w", err) + } + if ans != "yes" { + return errors.New("aborted") + } + } + + s, err := prompt.NewSelection(question, []string{"no", "yes"}, "WARNING: This operation is not reversible.", 2) + if err != nil { + return fmt.Errorf("could not initiate prompt: %w", err) + } + ans, err := s.Run() + if err != nil { + return fmt.Errorf("error running prompt: %w", err) + } + if ans != "yes" { + return errors.New("aborted") + } + + return nil +} diff --git a/server/cmd/mmctl/commands/utils_unix_test.go b/server/cmd/mmctl/commands/utils_unix_test.go new file mode 100644 index 0000000000..9ba03b6f9d --- /dev/null +++ b/server/cmd/mmctl/commands/utils_unix_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "io/ioutil" + "net" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckValidSocket(t *testing.T) { + t.Run("should return error if the file is not a socket", func(t *testing.T) { + f, err := ioutil.TempFile(os.TempDir(), "mmctl_socket_") + require.NoError(t, err) + defer os.Remove(f.Name()) + require.NoError(t, os.Chmod(f.Name(), 0600)) + + require.Error(t, checkValidSocket(f.Name())) + }) + + t.Run("should return error if the file has not the right permissions", func(t *testing.T) { + f, err := ioutil.TempFile(os.TempDir(), "mmctl_socket_") + require.NoError(t, err) + require.NoError(t, os.Remove(f.Name())) + + s, err := net.Listen("unix", f.Name()) + require.NoError(t, err) + defer s.Close() + require.NoError(t, os.Chmod(f.Name(), 0777)) + + require.Error(t, checkValidSocket(f.Name())) + }) + + t.Run("should return nil if the file is a socket and has the right permissions", func(t *testing.T) { + f, err := ioutil.TempFile(os.TempDir(), "mmctl_socket_") + require.NoError(t, err) + require.NoError(t, os.Remove(f.Name())) + + s, err := net.Listen("unix", f.Name()) + require.NoError(t, err) + defer s.Close() + require.NoError(t, os.Chmod(f.Name(), 0600)) + + require.NoError(t, checkValidSocket(f.Name())) + }) +} diff --git a/server/cmd/mmctl/commands/utils_windows.go b/server/cmd/mmctl/commands/utils_windows.go new file mode 100644 index 0000000000..e67df3bf89 --- /dev/null +++ b/server/cmd/mmctl/commands/utils_windows.go @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "errors" + "fmt" + "os" +) + +func checkValidSocket(socketPath string) error { + // check file mode and permissions + fi, err := os.Stat(socketPath) + if err != nil && os.IsNotExist(err) { + return fmt.Errorf("socket file %q doesn't exists, please check the server configuration for local mode", socketPath) + } else if err != nil { + return err + } + if fi.Mode() != expectedSocketMode { + return fmt.Errorf("invalid file mode for file %q, it must be a socket with 0600 permissions", socketPath) + } + + return nil +} + +func getConfirmation(question string, dbConfirmation bool) error { + if err := checkInteractiveTerminal(); err != nil { + return fmt.Errorf("could not proceed, either enable --confirm flag or use an interactive shell to complete operation: %w", err) + } + + var confirm string + if dbConfirmation { + fmt.Println("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") + } + } + + fmt.Println(question + " (YES/NO): ") + fmt.Scanln(&confirm) + if confirm != "YES" { + return errors.New("aborted: You did not answer YES exactly, in all capitals") + } + + return nil +} diff --git a/server/cmd/mmctl/commands/version.go b/server/cmd/mmctl/commands/version.go new file mode 100644 index 0000000000..b5a2956765 --- /dev/null +++ b/server/cmd/mmctl/commands/version.go @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + "runtime" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +var ( + Version = model.CurrentVersion + // SHA1 from git, output of $(git rev-parse HEAD) + gitCommit = "dev mode" + // State of git tree, either "clean" or "dirty" + gitTreeState = "unknown" + // Build date in ISO8601 format, output of $(date -u +'%Y-%m-%dT%H:%M:%SZ') + buildDate = "unknown" +) + +var VersionCmd = &cobra.Command{ + Use: "version", + Short: "Prints the version of mmctl.", + RunE: versionCmdF, +} + +func init() { + RootCmd.AddCommand(VersionCmd) +} + +func versionCmdF(cmd *cobra.Command, args []string) error { + v := getVersionInfo() + printer.PrintT("mmctl:\nVersion:\t{{.Version}}\nGitCommit:\t{{.GitCommit}}"+ + "\nGitTreeState:\t{{.GitTreeState}}\nBuildDate:\t{{.BuildDate}}\nGoVersion:\t{{.GoVersion}}"+ + "\nCompiler:\t{{.Compiler}}\nPlatform:\t{{.Platform}}", v) + return nil +} + +type Info struct { + Version string + GitCommit string + GitTreeState string + BuildDate string + GoVersion string + Compiler string + Platform string +} + +func getVersionInfo() Info { + return Info{ + Version: Version, + GitCommit: gitCommit, + GitTreeState: gitTreeState, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} diff --git a/server/cmd/mmctl/commands/webhook.go b/server/cmd/mmctl/commands/webhook.go new file mode 100644 index 0000000000..208ed81979 --- /dev/null +++ b/server/cmd/mmctl/commands/webhook.go @@ -0,0 +1,476 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client" + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type StoreResult struct { + Data interface{} + Err error +} + +var WebhookCmd = &cobra.Command{ + Use: "webhook", + Short: "Management of webhooks", +} + +var ListWebhookCmd = &cobra.Command{ + Use: "list", + Short: "List webhooks", + Long: "list all webhooks", + Example: " webhook list myteam", + RunE: withClient(listWebhookCmdF), +} + +var ShowWebhookCmd = &cobra.Command{ + Use: "show [webhookId]", + Short: "Show a webhook", + Long: "Show the webhook specified by [webhookId]", + Args: cobra.ExactArgs(1), + Example: " webhook show w16zb5tu3n1zkqo18goqry1je", + RunE: withClient(showWebhookCmdF), +} + +var CreateIncomingWebhookCmd = &cobra.Command{ + Use: "create-incoming", + Short: "Create incoming webhook", + Long: "create incoming webhook which allows external posting of messages to specific channel", + Example: " webhook create-incoming --channel [channelID] --user [userID] --display-name [displayName] --description [webhookDescription] --lock-to-channel --icon [iconURL]", + RunE: withClient(createIncomingWebhookCmdF), +} + +var ModifyIncomingWebhookCmd = &cobra.Command{ + Use: "modify-incoming", + Short: "Modify incoming webhook", + Long: "Modify existing incoming webhook by changing its title, description, channel or icon url", + Args: cobra.ExactArgs(1), + Example: " webhook modify-incoming [webhookID] --channel [channelID] --display-name [displayName] --description [webhookDescription] --lock-to-channel --icon [iconURL]", + RunE: withClient(modifyIncomingWebhookCmdF), +} + +var CreateOutgoingWebhookCmd = &cobra.Command{ + Use: "create-outgoing", + Short: "Create outgoing webhook", + Long: "create outgoing webhook which allows external posting of messages from a specific channel", + Example: ` webhook create-outgoing --team myteam --user myusername --display-name mywebhook --trigger-word "build" --trigger-word "test" --url http://localhost:8000/my-webhook-handler + webhook create-outgoing --team myteam --channel mychannel --user myusername --display-name mywebhook --description "My cool webhook" --trigger-when start --trigger-word build --trigger-word test --icon http://localhost:8000/my-slash-handler-bot-icon.png --url http://localhost:8000/my-webhook-handler --content-type "application/json"`, + RunE: withClient(createOutgoingWebhookCmdF), +} + +var ModifyOutgoingWebhookCmd = &cobra.Command{ + Use: "modify-outgoing", + Short: "Modify outgoing webhook", + Long: "Modify existing outgoing webhook by changing its title, description, channel, icon, url, content-type, and triggers", + Args: cobra.ExactArgs(1), + Example: ` webhook modify-outgoing [webhookId] --channel [channelId] --display-name [displayName] --description "New webhook description" --icon http://localhost:8000/my-slash-handler-bot-icon.png --url http://localhost:8000/my-webhook-handler --content-type "application/json" --trigger-word test --trigger-when start`, + RunE: withClient(modifyOutgoingWebhookCmdF), +} + +var DeleteWebhookCmd = &cobra.Command{ + Use: "delete", + Short: "Delete webhooks", + Long: "Delete webhook with given id", + Args: cobra.ExactArgs(1), + Example: " webhook delete [webhookID]", + RunE: withClient(deleteWebhookCmdF), +} + +func listWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + var teams []*model.Team + + if len(args) < 1 { + var err error + // If no team is specified, list all teams + teams, _, err = c.GetAllTeams("", 0, 100000000) + if err != nil { + return err + } + } else { + teams = getTeamsFromTeamArgs(c, args) + } + + for i, team := range teams { + if team == nil { + printer.PrintError("Unable to find team '" + args[i] + "'") + continue + } + + // Fetch all hooks with a very large limit so we get them all. + incomingResult := make(chan StoreResult, 1) + go func() { + incomingHooks, _, err := c.GetIncomingWebhooksForTeam(team.Id, 0, 100000000, "") + incomingResult <- StoreResult{Data: incomingHooks, Err: err} + close(incomingResult) + }() + outgoingResult := make(chan StoreResult, 1) + go func() { + outgoingHooks, _, err := c.GetOutgoingWebhooksForTeam(team.Id, 0, 100000000, "") + outgoingResult <- StoreResult{Data: outgoingHooks, Err: err} + close(outgoingResult) + }() + + if result := <-incomingResult; result.Err == nil { + hooks := result.Data.([]*model.IncomingWebhook) + for _, hook := range hooks { + printer.PrintT("Incoming:\t{{.DisplayName}} ({{.Id}}", hook) + } + } else { + printer.PrintError("Unable to list incoming webhooks for '" + team.Id + "'") + } + + if result := <-outgoingResult; result.Err == nil { + hooks := result.Data.([]*model.OutgoingWebhook) + for _, hook := range hooks { + printer.PrintT("Outgoing:\t {{.DisplayName}} ({{.Id}})", hook) + } + } else { + printer.PrintError("Unable to list outgoing webhooks for '" + team.Id + "'") + } + } + + return nil +} + +func createIncomingWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + channelArg, _ := command.Flags().GetString("channel") + channel := getChannelFromChannelArg(c, channelArg) + if channel == nil { + return errors.New("Unable to find channel '" + channelArg + "'") + } + + userArg, _ := command.Flags().GetString("user") + user := getUserFromUserArg(c, userArg) + if user == nil { + return errors.New("Unable to find user '" + userArg + "'") + } + + displayName, _ := command.Flags().GetString("display-name") + description, _ := command.Flags().GetString("description") + iconURL, _ := command.Flags().GetString("icon") + channelLocked, _ := command.Flags().GetBool("lock-to-channel") + + incomingWebhook := &model.IncomingWebhook{ + ChannelId: channel.Id, + DisplayName: displayName, + Description: description, + IconURL: iconURL, + ChannelLocked: channelLocked, + Username: user.Username, + UserId: user.Id, + } + + createdIncoming, _, err := c.CreateIncomingWebhook(incomingWebhook) + if err != nil { + printer.PrintError("Unable to create webhook") + return err + } + + tpl := `Id: {{.Id}} +Display Name: {{.DisplayName}}` + printer.PrintT(tpl, createdIncoming) + + return nil +} + +func modifyIncomingWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + webhookArg := args[0] + oldHook, _, err := c.GetIncomingWebhook(webhookArg, "") + if err != nil { + return errors.New("Unable to find webhook '" + webhookArg + "'") + } + + updatedHook := oldHook + + channelArg, _ := command.Flags().GetString("channel") + if channelArg != "" { + channel := getChannelFromChannelArg(c, channelArg) + if channel == nil { + return errors.New("Unable to find channel '" + channelArg + "'") + } + updatedHook.ChannelId = channel.Id + } + + displayName, _ := command.Flags().GetString("display-name") + if displayName != "" { + updatedHook.DisplayName = displayName + } + description, _ := command.Flags().GetString("description") + if description != "" { + updatedHook.Description = description + } + iconURL, _ := command.Flags().GetString("icon") + if iconURL != "" { + updatedHook.IconURL = iconURL + } + channelLocked, _ := command.Flags().GetBool("lock-to-channel") + updatedHook.ChannelLocked = channelLocked + + var newHook *model.IncomingWebhook + if newHook, _, err = c.UpdateIncomingWebhook(updatedHook); err != nil { + printer.PrintError("Unable to modify incoming webhook") + return err + } + + printer.PrintT("Webhook {{.Id}} successfully updated", newHook) + return nil +} + +func createOutgoingWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + teamArg, _ := command.Flags().GetString("team") + team := getTeamFromTeamArg(c, teamArg) + if team == nil { + return errors.New("Unable to find team: " + teamArg) + } + + userArg, _ := command.Flags().GetString("user") + user := getUserFromUserArg(c, userArg) + if user == nil { + return errors.New("Unable to find user: " + userArg) + } + + displayName, _ := command.Flags().GetString("display-name") + triggerWords, _ := command.Flags().GetStringArray("trigger-word") + callbackURLs, _ := command.Flags().GetStringArray("url") + + triggerWhenString, _ := command.Flags().GetString("trigger-when") + var triggerWhen int + switch triggerWhenString { + case "exact": + triggerWhen = 0 + case "start": + triggerWhen = 1 + default: + return errors.New("invalid trigger when parameter") + } + + description, _ := command.Flags().GetString("description") + contentType, _ := command.Flags().GetString("content-type") + iconURL, _ := command.Flags().GetString("icon") + + outgoingWebhook := &model.OutgoingWebhook{ + CreatorId: user.Id, + Username: user.Username, + TeamId: team.Id, + TriggerWords: triggerWords, + TriggerWhen: triggerWhen, + CallbackURLs: callbackURLs, + DisplayName: displayName, + Description: description, + ContentType: contentType, + IconURL: iconURL, + } + + channelArg, _ := command.Flags().GetString("channel") + if channelArg != "" { + channel := getChannelFromChannelArg(c, channelArg) + if channel != nil { + outgoingWebhook.ChannelId = channel.Id + } + } + + createdOutgoing, _, err := c.CreateOutgoingWebhook(outgoingWebhook) + if err != nil { + printer.PrintError("Unable to create outgoing webhook") + return err + } + + tpl := `Id: {{.Id}} +Display Name: {{.DisplayName}}` + printer.PrintT(tpl, createdOutgoing) + + return nil +} + +func modifyOutgoingWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + webhookArg := args[0] + oldHook, _, err := c.GetOutgoingWebhook(webhookArg) + if err != nil { + return errors.New("unable to find webhook '" + webhookArg + "'") + } + + updatedHook := oldHook + + channelArg, _ := command.Flags().GetString("channel") + if channelArg != "" { + channel := getChannelFromChannelArg(c, channelArg) + if channel == nil { + return errors.New("unable to find channel '" + channelArg + "'") + } + updatedHook.ChannelId = channel.Id + } + + displayName, _ := command.Flags().GetString("display-name") + if displayName != "" { + updatedHook.DisplayName = displayName + } + + description, _ := command.Flags().GetString("description") + if description != "" { + updatedHook.Description = description + } + + triggerWords, err := command.Flags().GetStringArray("trigger-word") + if err != nil { + return errors.Wrap(err, "invalid trigger-word parameter") + } + if len(triggerWords) > 0 { + updatedHook.TriggerWords = triggerWords + } + + triggerWhenString, _ := command.Flags().GetString("trigger-when") + if triggerWhenString != "" { + var triggerWhen int + switch triggerWhenString { + case "exact": + triggerWhen = 0 + case "start": + triggerWhen = 1 + default: + return errors.New("invalid trigger-when parameter") + } + updatedHook.TriggerWhen = triggerWhen + } + + iconURL, _ := command.Flags().GetString("icon") + if iconURL != "" { + updatedHook.IconURL = iconURL + } + + contentType, _ := command.Flags().GetString("content-type") + if contentType != "" { + updatedHook.ContentType = contentType + } + + callbackURLs, err := command.Flags().GetStringArray("url") + if err != nil { + return errors.Wrap(err, "invalid URL parameter") + } + if len(callbackURLs) > 0 { + updatedHook.CallbackURLs = callbackURLs + } + + var newHook *model.OutgoingWebhook + if newHook, _, err = c.UpdateOutgoingWebhook(updatedHook); err != nil { + printer.PrintError("Unable to modify outgoing webhook") + return err + } + + printer.PrintT("Webhook {{.Id}} successfully updated", newHook) + return nil +} + +func deleteWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + webhookID := args[0] + if incomingWebhook, _, err := c.GetIncomingWebhook(webhookID, ""); err == nil { + _, err := c.DeleteIncomingWebhook(webhookID) + if err != nil { + printer.PrintError("Unable to delete webhook '" + webhookID + "'") + return err + } + printer.PrintT("Webhook {{.Id}} successfully deleted", incomingWebhook) + return nil + } + + if outgoingWebhook, _, err := c.GetOutgoingWebhook(webhookID); err == nil { + _, err := c.DeleteOutgoingWebhook(webhookID) + if err != nil { + printer.PrintError("Unable to delete webhook '" + webhookID + "'") + return err + } + + printer.PrintT("Webhook {{.Id}} successfully deleted", outgoingWebhook) + return nil + } + + return errors.New("Webhook with id '" + webhookID + "' not found") +} + +func showWebhookCmdF(c client.Client, command *cobra.Command, args []string) error { + printer.SetSingle(true) + + webhookID := args[0] + if incomingWebhook, _, err := c.GetIncomingWebhook(webhookID, ""); err == nil { + printer.Print(*incomingWebhook) + return nil + } + + if outgoingWebhook, _, err := c.GetOutgoingWebhook(webhookID); err == nil { + printer.Print(*outgoingWebhook) + return nil + } + + return errors.New("Webhook with id '" + webhookID + "' not found") +} + +func init() { + CreateIncomingWebhookCmd.Flags().String("channel", "", "Channel ID (required)") + _ = CreateIncomingWebhookCmd.MarkFlagRequired("channel") + CreateIncomingWebhookCmd.Flags().String("user", "", "User ID (required)") + _ = CreateIncomingWebhookCmd.MarkFlagRequired("user") + CreateIncomingWebhookCmd.Flags().String("display-name", "", "Incoming webhook display name") + CreateIncomingWebhookCmd.Flags().String("description", "", "Incoming webhook description") + CreateIncomingWebhookCmd.Flags().String("icon", "", "Icon URL") + CreateIncomingWebhookCmd.Flags().Bool("lock-to-channel", false, "Lock to channel") + + ModifyIncomingWebhookCmd.Flags().String("channel", "", "Channel ID") + ModifyIncomingWebhookCmd.Flags().String("display-name", "", "Incoming webhook display name") + ModifyIncomingWebhookCmd.Flags().String("description", "", "Incoming webhook description") + ModifyIncomingWebhookCmd.Flags().String("icon", "", "Icon URL") + ModifyIncomingWebhookCmd.Flags().Bool("lock-to-channel", false, "Lock to channel") + + CreateOutgoingWebhookCmd.Flags().String("team", "", "Team name or ID (required)") + _ = CreateOutgoingWebhookCmd.MarkFlagRequired("team") + CreateOutgoingWebhookCmd.Flags().String("channel", "", "Channel name or ID") + CreateOutgoingWebhookCmd.Flags().String("user", "", "User username, email, or ID (required)") + _ = CreateOutgoingWebhookCmd.MarkFlagRequired("user") + CreateOutgoingWebhookCmd.Flags().String("display-name", "", "Outgoing webhook display name (required)") + _ = CreateOutgoingWebhookCmd.MarkFlagRequired("display-name") + CreateOutgoingWebhookCmd.Flags().String("description", "", "Outgoing webhook description") + CreateOutgoingWebhookCmd.Flags().StringArray("trigger-word", []string{}, "Word to trigger webhook (required)") + _ = CreateOutgoingWebhookCmd.MarkFlagRequired("trigger-word") + CreateOutgoingWebhookCmd.Flags().String("trigger-when", "exact", "When to trigger webhook (exact: for first word matches a trigger word exactly, start: for first word starts with a trigger word)") + CreateOutgoingWebhookCmd.Flags().String("icon", "", "Icon URL") + CreateOutgoingWebhookCmd.Flags().StringArray("url", []string{}, "Callback URL (required)") + _ = CreateOutgoingWebhookCmd.MarkFlagRequired("url") + CreateOutgoingWebhookCmd.Flags().String("content-type", "", "Content-type") + + ModifyOutgoingWebhookCmd.Flags().String("channel", "", "Channel name or ID") + ModifyOutgoingWebhookCmd.Flags().String("display-name", "", "Outgoing webhook display name") + ModifyOutgoingWebhookCmd.Flags().String("description", "", "Outgoing webhook description") + ModifyOutgoingWebhookCmd.Flags().StringArray("trigger-word", []string{}, "Word to trigger webhook") + ModifyOutgoingWebhookCmd.Flags().String("trigger-when", "", "When to trigger webhook (exact: for first word matches a trigger word exactly, start: for first word starts with a trigger word)") + ModifyOutgoingWebhookCmd.Flags().String("icon", "", "Icon URL") + ModifyOutgoingWebhookCmd.Flags().StringArray("url", []string{}, "Callback URL") + ModifyOutgoingWebhookCmd.Flags().String("content-type", "", "Content-type") + + WebhookCmd.AddCommand( + ListWebhookCmd, + CreateIncomingWebhookCmd, + ModifyIncomingWebhookCmd, + CreateOutgoingWebhookCmd, + ModifyOutgoingWebhookCmd, + DeleteWebhookCmd, + ShowWebhookCmd, + ) + + RootCmd.AddCommand(WebhookCmd) +} diff --git a/server/cmd/mmctl/commands/webhook_e2e_test.go b/server/cmd/mmctl/commands/webhook_e2e_test.go new file mode 100644 index 0000000000..5409835124 --- /dev/null +++ b/server/cmd/mmctl/commands/webhook_e2e_test.go @@ -0,0 +1,39 @@ +// 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/server/v8/cmd/mmctl/printer" + + "github.com/mattermost/mattermost-server/server/public/model" +) + +func (s *MmctlE2ETestSuite) TestCreateIncomingWebhookCmd() { + s.SetupTestHelper().InitBasic() + + s.Run("provided values should be consistent with the created webhook", func() { + printer.Clean() + + cmd := &cobra.Command{} + cmd.Flags().String("channel", s.th.BasicChannel.Id, "") + cmd.Flags().String("user", s.th.BasicUser2.Username, "") + cmd.Flags().String("display-name", "webhook-test-1", "") + cmd.Flags().String("description", "webhook-test-1-desc", "") + cmd.Flags().Bool("lock-to-channel", true, "") + + err := createIncomingWebhookCmdF(s.th.SystemAdminClient, cmd, nil) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Len(printer.GetErrorLines(), 0) + + hook := printer.GetLines()[0].(*model.IncomingWebhook) + s.Require().Equal(s.th.BasicUser2.Id, hook.UserId) + s.Require().Equal(s.th.BasicChannel.Id, hook.ChannelId) + s.Require().Equal("webhook-test-1", hook.DisplayName) + s.Require().Equal("webhook-test-1-desc", hook.Description) + s.Require().True(hook.ChannelLocked) + }) +} diff --git a/server/cmd/mmctl/commands/webhook_test.go b/server/cmd/mmctl/commands/webhook_test.go new file mode 100644 index 0000000000..1e2098356d --- /dev/null +++ b/server/cmd/mmctl/commands/webhook_test.go @@ -0,0 +1,698 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "net/http" + "strconv" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/printer" + + "github.com/spf13/cobra" +) + +func (s *MmctlUnitTestSuite) TestListWebhookCmd() { + teamID := "teamID" + incomingWebhookID := "incomingWebhookID" + incomingWebhookDisplayName := "incomingWebhookDisplayName" + outgoingWebhookID := "outgoingWebhookID" + outgoingWebhookDisplayName := "outgoingWebhookDisplayName" + + s.Run("Listing all webhooks", func() { + printer.Clean() + + mockTeam := model.Team{ + Id: teamID, + } + mockIncomingWebhook := model.IncomingWebhook{ + Id: incomingWebhookID, + DisplayName: incomingWebhookDisplayName, + } + mockOutgoingWebhook := model.OutgoingWebhook{ + Id: outgoingWebhookID, + DisplayName: outgoingWebhookDisplayName, + } + + s.client. + EXPECT(). + GetAllTeams("", 0, 100000000). + Return([]*model.Team{&mockTeam}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetIncomingWebhooksForTeam(teamID, 0, 100000000, ""). + Return([]*model.IncomingWebhook{&mockIncomingWebhook}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhooksForTeam(teamID, 0, 100000000, ""). + Return([]*model.OutgoingWebhook{&mockOutgoingWebhook}, &model.Response{}, nil). + Times(1) + + err := listWebhookCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 2) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&mockIncomingWebhook, printer.GetLines()[0]) + s.Require().Equal(&mockOutgoingWebhook, printer.GetLines()[1]) + }) + + s.Run("List webhooks by team", func() { + printer.Clean() + + mockTeam := model.Team{ + Id: teamID, + } + mockIncomingWebhook := model.IncomingWebhook{ + Id: incomingWebhookID, + DisplayName: incomingWebhookDisplayName, + } + mockOutgoingWebhook := model.OutgoingWebhook{ + Id: outgoingWebhookID, + DisplayName: outgoingWebhookDisplayName, + } + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetIncomingWebhooksForTeam(teamID, 0, 100000000, ""). + Return([]*model.IncomingWebhook{&mockIncomingWebhook}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhooksForTeam(teamID, 0, 100000000, ""). + Return([]*model.OutgoingWebhook{&mockOutgoingWebhook}, &model.Response{}, nil). + Times(1) + + err := listWebhookCmdF(s.client, &cobra.Command{}, []string{teamID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 2) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&mockIncomingWebhook, printer.GetLines()[0]) + s.Require().Equal(&mockOutgoingWebhook, printer.GetLines()[1]) + }) + + s.Run("Unable to list webhooks", func() { + printer.Clean() + + mockTeam := model.Team{ + Id: teamID, + } + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetAllTeams("", 0, 100000000). + Return([]*model.Team{&mockTeam}, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetIncomingWebhooksForTeam(teamID, 0, 100000000, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhooksForTeam(teamID, 0, 100000000, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := listWebhookCmdF(s.client, &cobra.Command{}, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 2) + s.Require().Equal("Unable to list incoming webhooks for '"+teamID+"'", printer.GetErrorLines()[0]) + s.Require().Equal("Unable to list outgoing webhooks for '"+teamID+"'", printer.GetErrorLines()[1]) + }) +} + +func (s *MmctlUnitTestSuite) TestCreateIncomingWebhookCmd() { + incomingWebhookID := "incomingWebhookID" + channelID := "channelID" + userID := "userID" + emailID := "emailID" + userName := "userName" + displayName := "displayName" + + cmd := &cobra.Command{} + cmd.Flags().String("channel", channelID, "") + cmd.Flags().String("user", emailID, "") + cmd.Flags().String("display-name", displayName, "") + + s.Run("Successfully create new incoming webhook", func() { + printer.Clean() + + mockChannel := model.Channel{ + Id: channelID, + } + mockUser := model.User{ + Id: userID, + Email: emailID, + Username: userName, + } + mockIncomingWebhook := model.IncomingWebhook{ + ChannelId: channelID, + Username: userName, + DisplayName: displayName, + UserId: userID, + } + returnedIncomingWebhook := mockIncomingWebhook + returnedIncomingWebhook.Id = incomingWebhookID + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateIncomingWebhook(&mockIncomingWebhook). + Return(&returnedIncomingWebhook, &model.Response{}, nil). + Times(1) + + err := createIncomingWebhookCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&returnedIncomingWebhook, printer.GetLines()[0]) + }) + + s.Run("Incoming webhook creation error", func() { + printer.Clean() + + mockChannel := model.Channel{ + Id: channelID, + } + mockUser := model.User{ + Id: userID, + Email: emailID, + Username: userName, + } + mockIncomingWebhook := model.IncomingWebhook{ + ChannelId: channelID, + Username: userName, + DisplayName: displayName, + UserId: userID, + } + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetChannel(channelID, ""). + Return(&mockChannel, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateIncomingWebhook(&mockIncomingWebhook). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := createIncomingWebhookCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to create webhook", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestModifyIncomingWebhookCmd() { + incomingWebhookID := "incomingWebhookID" + channelID := "channelID" + userName := "userName" + displayName := "displayName" + + s.Run("Successfully modify incoming webhook", func() { + printer.Clean() + + mockIncomingWebhook := model.IncomingWebhook{ + Id: incomingWebhookID, + ChannelId: channelID, + Username: userName, + DisplayName: displayName, + ChannelLocked: false, + } + + lockToChannel := true + updatedIncomingWebhook := mockIncomingWebhook + updatedIncomingWebhook.ChannelLocked = lockToChannel + + cmd := &cobra.Command{} + + _ = cmd.Flags().Set("lock-to-channel", strconv.FormatBool(lockToChannel)) + + s.client. + EXPECT(). + GetIncomingWebhook(incomingWebhookID, ""). + Return(&mockIncomingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateIncomingWebhook(&mockIncomingWebhook). + Return(&updatedIncomingWebhook, &model.Response{}, nil). + Times(1) + + err := modifyIncomingWebhookCmdF(s.client, cmd, []string{incomingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&updatedIncomingWebhook, printer.GetLines()[0]) + }) + + s.Run("modify incoming webhook errored", func() { + printer.Clean() + + mockIncomingWebhook := model.IncomingWebhook{ + Id: incomingWebhookID, + ChannelId: channelID, + Username: userName, + DisplayName: displayName, + ChannelLocked: false, + } + + lockToChannel := true + + mockError := errors.New("mock error") + + cmd := &cobra.Command{} + + _ = cmd.Flags().Set("lock-to-channel", strconv.FormatBool(lockToChannel)) + + s.client. + EXPECT(). + GetIncomingWebhook(incomingWebhookID, ""). + Return(&mockIncomingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateIncomingWebhook(&mockIncomingWebhook). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := modifyIncomingWebhookCmdF(s.client, cmd, []string{incomingWebhookID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to modify incoming webhook", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestCreateOutgoingWebhookCmd() { + teamID := "teamID" + outgoingWebhookID := "outgoingWebhookID" + userID := "userID" + emailID := "emailID" + userName := "userName" + triggerWhen := "exact" + + cmd := &cobra.Command{} + cmd.Flags().String("team", teamID, "") + cmd.Flags().String("user", emailID, "") + cmd.Flags().String("trigger-when", triggerWhen, "") + + s.Run("Successfully create outgoing webhook", func() { + printer.Clean() + + mockTeam := model.Team{ + Id: teamID, + } + mockUser := model.User{ + Id: userID, + Email: emailID, + Username: userName, + } + mockOutgoingWebhook := model.OutgoingWebhook{ + CreatorId: userID, + Username: userName, + TeamId: teamID, + TriggerWords: []string{}, + TriggerWhen: 0, + CallbackURLs: []string{}, + } + + createdOutgoingWebhook := mockOutgoingWebhook + createdOutgoingWebhook.Id = outgoingWebhookID + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateOutgoingWebhook(&mockOutgoingWebhook). + Return(&createdOutgoingWebhook, &model.Response{}, nil). + Times(1) + + err := createOutgoingWebhookCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&createdOutgoingWebhook, printer.GetLines()[0]) + }) + + s.Run("Create outgoing webhook error", func() { + printer.Clean() + + mockTeam := model.Team{ + Id: teamID, + } + mockUser := model.User{ + Id: userID, + Email: emailID, + Username: userName, + } + mockOutgoingWebhook := model.OutgoingWebhook{ + CreatorId: userID, + Username: userName, + TeamId: teamID, + TriggerWords: []string{}, + TriggerWhen: 0, + CallbackURLs: []string{}, + } + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetTeam(teamID, ""). + Return(&mockTeam, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetUserByEmail(emailID, ""). + Return(&mockUser, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + CreateOutgoingWebhook(&mockOutgoingWebhook). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := createOutgoingWebhookCmdF(s.client, cmd, []string{}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to create outgoing webhook", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestModifyOutgoingWebhookCmd() { + outgoingWebhookID := "outgoingWebhookID" + + s.Run("Successfully modify outgoing webhook", func() { + printer.Clean() + + mockOutgoingWebhook := model.OutgoingWebhook{ + Id: outgoingWebhookID, + TriggerWords: []string{}, + CallbackURLs: []string{}, + TriggerWhen: 0, + } + + updatedOutgoingWebhook := mockOutgoingWebhook + updatedOutgoingWebhook.TriggerWhen = 1 + + cmd := &cobra.Command{} + cmd.Flags().StringArray("url", []string{}, "") + cmd.Flags().StringArray("trigger-word", []string{}, "") + cmd.Flags().String("trigger-when", "start", "") + + s.client. + EXPECT(). + GetOutgoingWebhook(outgoingWebhookID). + Return(&mockOutgoingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateOutgoingWebhook(&mockOutgoingWebhook). + Return(&updatedOutgoingWebhook, &model.Response{}, nil). + Times(1) + + err := modifyOutgoingWebhookCmdF(s.client, cmd, []string{outgoingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&updatedOutgoingWebhook, printer.GetLines()[0]) + }) + + s.Run("Modify outgoing webhook error", func() { + printer.Clean() + + mockOutgoingWebhook := model.OutgoingWebhook{ + Id: outgoingWebhookID, + TriggerWords: []string{}, + CallbackURLs: []string{}, + TriggerWhen: 0, + } + mockError := errors.New("mock error") + + cmd := &cobra.Command{} + cmd.Flags().StringArray("url", []string{}, "") + cmd.Flags().StringArray("trigger-word", []string{}, "") + cmd.Flags().String("trigger-when", "start", "") + + s.client. + EXPECT(). + GetOutgoingWebhook(outgoingWebhookID). + Return(&mockOutgoingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + UpdateOutgoingWebhook(&mockOutgoingWebhook). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := modifyOutgoingWebhookCmdF(s.client, cmd, []string{outgoingWebhookID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to modify outgoing webhook", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestDeleteWebhookCmd() { + incomingWebhookID := "incomingWebhookID" + outgoingWebhookID := "outgoingWebhookID" + + s.Run("Successfully delete incoming webhook", func() { + printer.Clean() + + mockIncomingWebhook := model.IncomingWebhook{Id: incomingWebhookID} + + s.client. + EXPECT(). + GetIncomingWebhook(incomingWebhookID, ""). + Return(&mockIncomingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteIncomingWebhook(incomingWebhookID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := deleteWebhookCmdF(s.client, &cobra.Command{}, []string{incomingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&mockIncomingWebhook, printer.GetLines()[0]) + }) + + s.Run("Successfully delete outgoing webhook", func() { + printer.Clean() + + mockError := errors.New("mock error") + mockOutgoingWebhook := model.OutgoingWebhook{Id: outgoingWebhookID} + + s.client. + EXPECT(). + GetIncomingWebhook(outgoingWebhookID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhook(outgoingWebhookID). + Return(&mockOutgoingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteOutgoingWebhook(outgoingWebhookID). + Return(&model.Response{StatusCode: http.StatusOK}, nil). + Times(1) + + err := deleteWebhookCmdF(s.client, &cobra.Command{}, []string{outgoingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(&mockOutgoingWebhook, printer.GetLines()[0]) + }) + + s.Run("delete incoming webhook error", func() { + printer.Clean() + + mockIncomingWebhook := model.IncomingWebhook{Id: incomingWebhookID} + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetIncomingWebhook(incomingWebhookID, ""). + Return(&mockIncomingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteIncomingWebhook(incomingWebhookID). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := deleteWebhookCmdF(s.client, &cobra.Command{}, []string{incomingWebhookID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to delete webhook '"+incomingWebhookID+"'", printer.GetErrorLines()[0]) + }) + + s.Run("delete outgoing webhook error", func() { + printer.Clean() + + mockError := errors.New("mock error") + mockOutgoingWebhook := model.OutgoingWebhook{Id: outgoingWebhookID} + + s.client. + EXPECT(). + GetIncomingWebhook(outgoingWebhookID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhook(outgoingWebhookID). + Return(&mockOutgoingWebhook, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + DeleteOutgoingWebhook(outgoingWebhookID). + Return(&model.Response{StatusCode: http.StatusBadRequest}, mockError). + Times(1) + + err := deleteWebhookCmdF(s.client, &cobra.Command{}, []string{outgoingWebhookID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 1) + s.Require().Equal("Unable to delete webhook '"+outgoingWebhookID+"'", printer.GetErrorLines()[0]) + }) +} + +func (s *MmctlUnitTestSuite) TestShowWebhookCmd() { + incomingWebhookID := "incomingWebhookID" + outgoingWebhookID := "outgoingWebhookID" + nonExistentID := "nonExistentID" + + s.Run("Successfully show incoming webhook", func() { + printer.Clean() + + mockIncomingWebhook := model.IncomingWebhook{Id: incomingWebhookID} + + s.client. + EXPECT(). + GetIncomingWebhook(incomingWebhookID, ""). + Return(&mockIncomingWebhook, &model.Response{}, nil). + Times(1) + + err := showWebhookCmdF(s.client, &cobra.Command{}, []string{incomingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(mockIncomingWebhook, printer.GetLines()[0]) + }) + + s.Run("Successfully show outgoing webhook", func() { + printer.Clean() + + mockError := errors.New("mock error") + mockOutgoingWebhook := model.OutgoingWebhook{Id: outgoingWebhookID} + + s.client. + EXPECT(). + GetIncomingWebhook(outgoingWebhookID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhook(outgoingWebhookID). + Return(&mockOutgoingWebhook, &model.Response{}, nil). + Times(1) + + err := showWebhookCmdF(s.client, &cobra.Command{}, []string{outgoingWebhookID}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal(mockOutgoingWebhook, printer.GetLines()[0]) + }) + + s.Run("Error in show webhook", func() { + printer.Clean() + + mockError := errors.New("mock error") + + s.client. + EXPECT(). + GetIncomingWebhook(nonExistentID, ""). + Return(nil, &model.Response{}, mockError). + Times(1) + + s.client. + EXPECT(). + GetOutgoingWebhook(nonExistentID). + Return(nil, &model.Response{}, mockError). + Times(1) + + err := showWebhookCmdF(s.client, &cobra.Command{}, []string{nonExistentID}) + s.Require().Error(err) + s.Len(printer.GetLines(), 0) + s.Len(printer.GetErrorLines(), 0) + s.Require().Equal("Webhook with id '"+nonExistentID+"' not found", err.Error()) + }) +} diff --git a/server/cmd/mmctl/commands/websockets.go b/server/cmd/mmctl/commands/websockets.go new file mode 100644 index 0000000000..22468386e7 --- /dev/null +++ b/server/cmd/mmctl/commands/websockets.go @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var WebsocketCmd = &cobra.Command{ + Use: "websocket", + Short: "Display websocket in a human-readable format", + RunE: websocketCmdF, +} + +func init() { + RootCmd.AddCommand(WebsocketCmd) +} + +func websocketCmdF(cmd *cobra.Command, args []string) error { + c, err := InitWebSocketClient() + if err != nil { + return err + } + appErr := c.Connect() + if appErr != nil { + return errors.New(appErr.Error()) + } + + c.Listen() + fmt.Println("Press CTRL+C to exit") + for { + event := <-c.EventChannel + data, err := event.ToJSON() + if err != nil { + fmt.Println(err.Error()) + } else { + fmt.Println(string(data)) + } + } +} diff --git a/server/cmd/mmctl/docs/mmctl.rst b/server/cmd/mmctl/docs/mmctl.rst new file mode 100644 index 0000000000..413680016c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl.rst @@ -0,0 +1,61 @@ +.. _mmctl: + +mmctl +----- + +Remote client for the Open Source, self-hosted Slack-alternative + +Synopsis +~~~~~~~~ + + +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 + +Options +~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + -h, --help help for mmctl + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances +* `mmctl bot `_ - Management of bots +* `mmctl channel `_ - Management of channels +* `mmctl command `_ - Management of slash commands +* `mmctl completion `_ - Generates autocompletion scripts for bash and zsh +* `mmctl config `_ - Configuration +* `mmctl docs `_ - Generates mmctl documentation +* `mmctl export `_ - Management of exports +* `mmctl extract `_ - Management of content extraction job. +* `mmctl group `_ - Management of groups +* `mmctl import `_ - Management of imports +* `mmctl integrity `_ - Check database records integrity. +* `mmctl ldap `_ - LDAP related utilities +* `mmctl license `_ - Licensing commands +* `mmctl logs `_ - Display logs in a human-readable format +* `mmctl permissions `_ - Management of permissions +* `mmctl plugin `_ - Management of plugins +* `mmctl post `_ - Management of posts +* `mmctl roles `_ - Manage user roles +* `mmctl saml `_ - SAML related utilities +* `mmctl sampledata `_ - Generate sample data +* `mmctl system `_ - System management +* `mmctl team `_ - Management of teams +* `mmctl token `_ - manage users' access tokens +* `mmctl user `_ - Management of users +* `mmctl version `_ - Prints the version of mmctl. +* `mmctl webhook `_ - Management of webhooks +* `mmctl websocket `_ - Display websocket in a human-readable format + diff --git a/server/cmd/mmctl/docs/mmctl_auth.rst b/server/cmd/mmctl/docs/mmctl_auth.rst new file mode 100644 index 0000000000..adbd2191ae --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth.rst @@ -0,0 +1,47 @@ +.. _mmctl_auth: + +mmctl auth +---------- + +Manages the credentials of the remote Mattermost instances + +Synopsis +~~~~~~~~ + + +Manages the credentials of the remote Mattermost instances + +Options +~~~~~~~ + +:: + + -h, --help help for auth + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl auth clean `_ - Clean all credentials +* `mmctl auth current `_ - Show current user credentials +* `mmctl auth delete `_ - Delete an credentials +* `mmctl auth list `_ - Lists the credentials +* `mmctl auth login `_ - Login into an instance +* `mmctl auth renew `_ - Renews a set of credentials +* `mmctl auth set `_ - Set the credentials to use + diff --git a/server/cmd/mmctl/docs/mmctl_auth_clean.rst b/server/cmd/mmctl/docs/mmctl_auth_clean.rst new file mode 100644 index 0000000000..3b538198a6 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_clean.rst @@ -0,0 +1,51 @@ +.. _mmctl_auth_clean: + +mmctl auth clean +---------------- + +Clean all credentials + +Synopsis +~~~~~~~~ + + +Clean the currently stored credentials + +:: + + mmctl auth clean [flags] + +Examples +~~~~~~~~ + +:: + + auth clean + +Options +~~~~~~~ + +:: + + -h, --help help for clean + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_current.rst b/server/cmd/mmctl/docs/mmctl_auth_current.rst new file mode 100644 index 0000000000..2200e98555 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_current.rst @@ -0,0 +1,51 @@ +.. _mmctl_auth_current: + +mmctl auth current +------------------ + +Show current user credentials + +Synopsis +~~~~~~~~ + + +Show the currently stored user credentials + +:: + + mmctl auth current [flags] + +Examples +~~~~~~~~ + +:: + + auth current + +Options +~~~~~~~ + +:: + + -h, --help help for current + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_delete.rst b/server/cmd/mmctl/docs/mmctl_auth_delete.rst new file mode 100644 index 0000000000..524eb7d7ce --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_delete.rst @@ -0,0 +1,51 @@ +.. _mmctl_auth_delete: + +mmctl auth delete +----------------- + +Delete an credentials + +Synopsis +~~~~~~~~ + + +Delete an credentials by its name + +:: + + mmctl auth delete [server name] [flags] + +Examples +~~~~~~~~ + +:: + + auth delete local-server + +Options +~~~~~~~ + +:: + + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_list.rst b/server/cmd/mmctl/docs/mmctl_auth_list.rst new file mode 100644 index 0000000000..6ec3231702 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_auth_list: + +mmctl auth list +--------------- + +Lists the credentials + +Synopsis +~~~~~~~~ + + +Print a list of the registered credentials + +:: + + mmctl auth list [flags] + +Examples +~~~~~~~~ + +:: + + auth list + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_login.rst b/server/cmd/mmctl/docs/mmctl_auth_login.rst new file mode 100644 index 0000000000..40cf0d6178 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_login.rst @@ -0,0 +1,60 @@ +.. _mmctl_auth_login: + +mmctl auth login +---------------- + +Login into an instance + +Synopsis +~~~~~~~~ + + +Login into an instance and store credentials + +:: + + mmctl auth login [instance url] --name [server name] --username [username] --password-file [password-file] [flags] + +Examples +~~~~~~~~ + +:: + + 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 + +Options +~~~~~~~ + +:: + + -t, --access-token-file string Access token file to be read to use instead of username/password + -h, --help help for login + -m, --mfa-token string MFA token for the credentials + -n, --name string Name for the credentials + --no-activate If present, it won't activate the credentials after login + -f, --password-file string Password file to be read for the credentials + -u, --username string Username for the credentials + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_renew.rst b/server/cmd/mmctl/docs/mmctl_auth_renew.rst new file mode 100644 index 0000000000..ac927dd251 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_renew.rst @@ -0,0 +1,54 @@ +.. _mmctl_auth_renew: + +mmctl auth renew +---------------- + +Renews a set of credentials + +Synopsis +~~~~~~~~ + + +Renews the credentials for a given server + +:: + + mmctl auth renew [flags] + +Examples +~~~~~~~~ + +:: + + auth renew local-server + +Options +~~~~~~~ + +:: + + -t, --access-token-file string Access token file to be read to use instead of username/password + -h, --help help for renew + -m, --mfa-token string MFA token for the credentials + -f, --password-file string Password file to be read for the credentials + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_auth_set.rst b/server/cmd/mmctl/docs/mmctl_auth_set.rst new file mode 100644 index 0000000000..d085dbe6ce --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_auth_set.rst @@ -0,0 +1,51 @@ +.. _mmctl_auth_set: + +mmctl auth set +-------------- + +Set the credentials to use + +Synopsis +~~~~~~~~ + + +Set an credentials to use in the following commands + +:: + + mmctl auth set [server name] [flags] + +Examples +~~~~~~~~ + +:: + + auth set local-server + +Options +~~~~~~~ + +:: + + -h, --help help for set + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl auth `_ - Manages the credentials of the remote Mattermost instances + diff --git a/server/cmd/mmctl/docs/mmctl_bot.rst b/server/cmd/mmctl/docs/mmctl_bot.rst new file mode 100644 index 0000000000..6c8bdf837b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot.rst @@ -0,0 +1,46 @@ +.. _mmctl_bot: + +mmctl bot +--------- + +Management of bots + +Synopsis +~~~~~~~~ + + +Management of bots + +Options +~~~~~~~ + +:: + + -h, --help help for bot + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl bot assign `_ - Assign bot +* `mmctl bot create `_ - Create bot +* `mmctl bot disable `_ - Disable bot +* `mmctl bot enable `_ - Enable bot +* `mmctl bot list `_ - List bots +* `mmctl bot update `_ - Update bot + diff --git a/server/cmd/mmctl/docs/mmctl_bot_assign.rst b/server/cmd/mmctl/docs/mmctl_bot_assign.rst new file mode 100644 index 0000000000..592f6c71be --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_assign.rst @@ -0,0 +1,51 @@ +.. _mmctl_bot_assign: + +mmctl bot assign +---------------- + +Assign bot + +Synopsis +~~~~~~~~ + + +Assign the ownership of a bot to another user + +:: + + mmctl bot assign [bot-username] [new-owner-username] [flags] + +Examples +~~~~~~~~ + +:: + + bot assign testbot user2 + +Options +~~~~~~~ + +:: + + -h, --help help for assign + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_bot_create.rst b/server/cmd/mmctl/docs/mmctl_bot_create.rst new file mode 100644 index 0000000000..89c1fcc5db --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_create.rst @@ -0,0 +1,54 @@ +.. _mmctl_bot_create: + +mmctl bot create +---------------- + +Create bot + +Synopsis +~~~~~~~~ + + +Create bot. + +:: + + mmctl bot create [username] [flags] + +Examples +~~~~~~~~ + +:: + + bot create testbot + +Options +~~~~~~~ + +:: + + --description string Optional. The description text for the new bot. + --display-name string Optional. The display name for the new bot. + -h, --help help for create + --with-token Optional. Auto genreate access token for the bot. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_bot_disable.rst b/server/cmd/mmctl/docs/mmctl_bot_disable.rst new file mode 100644 index 0000000000..f8a955718c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_disable.rst @@ -0,0 +1,51 @@ +.. _mmctl_bot_disable: + +mmctl bot disable +----------------- + +Disable bot + +Synopsis +~~~~~~~~ + + +Disable an enabled bot + +:: + + mmctl bot disable [username] [flags] + +Examples +~~~~~~~~ + +:: + + bot disable testbot + +Options +~~~~~~~ + +:: + + -h, --help help for disable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_bot_enable.rst b/server/cmd/mmctl/docs/mmctl_bot_enable.rst new file mode 100644 index 0000000000..68ef69bd1c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_enable.rst @@ -0,0 +1,51 @@ +.. _mmctl_bot_enable: + +mmctl bot enable +---------------- + +Enable bot + +Synopsis +~~~~~~~~ + + +Enable a disabled bot + +:: + + mmctl bot enable [username] [flags] + +Examples +~~~~~~~~ + +:: + + bot enable testbot + +Options +~~~~~~~ + +:: + + -h, --help help for enable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_bot_list.rst b/server/cmd/mmctl/docs/mmctl_bot_list.rst new file mode 100644 index 0000000000..3b0da06409 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_list.rst @@ -0,0 +1,53 @@ +.. _mmctl_bot_list: + +mmctl bot list +-------------- + +List bots + +Synopsis +~~~~~~~~ + + +List the bots users. + +:: + + mmctl bot list [flags] + +Examples +~~~~~~~~ + +:: + + bot list + +Options +~~~~~~~ + +:: + + --all Optional. Show all bots (including deleleted and orphaned). + -h, --help help for list + --orphaned Optional. Only show orphaned bots. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_bot_update.rst b/server/cmd/mmctl/docs/mmctl_bot_update.rst new file mode 100644 index 0000000000..c92bcb1a3e --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_bot_update.rst @@ -0,0 +1,54 @@ +.. _mmctl_bot_update: + +mmctl bot update +---------------- + +Update bot + +Synopsis +~~~~~~~~ + + +Update bot information. + +:: + + mmctl bot update [username] [flags] + +Examples +~~~~~~~~ + +:: + + bot update testbot --username newbotusername + +Options +~~~~~~~ + +:: + + --description string Optional. The new description text for the bot. + --display-name string Optional. The new display name for the bot. + -h, --help help for update + --username string Optional. The new username for the bot. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl bot `_ - Management of bots + diff --git a/server/cmd/mmctl/docs/mmctl_channel.rst b/server/cmd/mmctl/docs/mmctl_channel.rst new file mode 100644 index 0000000000..2e74a95a62 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel.rst @@ -0,0 +1,50 @@ +.. _mmctl_channel: + +mmctl channel +------------- + +Management of channels + +Synopsis +~~~~~~~~ + + +Management of channels + +Options +~~~~~~~ + +:: + + -h, --help help for channel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl channel archive `_ - Archive channels +* `mmctl channel create `_ - Create a channel +* `mmctl channel delete `_ - Delete channels +* `mmctl channel list `_ - List all channels on specified teams. +* `mmctl channel modify `_ - Modify a channel's public/private type +* `mmctl channel move `_ - Moves channels to the specified team +* `mmctl channel rename `_ - Rename channel +* `mmctl channel search `_ - Search a channel +* `mmctl channel unarchive `_ - Unarchive some channels +* `mmctl channel users `_ - Management of channel users + diff --git a/server/cmd/mmctl/docs/mmctl_channel_archive.rst b/server/cmd/mmctl/docs/mmctl_channel_archive.rst new file mode 100644 index 0000000000..52bce3c12a --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_archive.rst @@ -0,0 +1,53 @@ +.. _mmctl_channel_archive: + +mmctl channel archive +--------------------- + +Archive channels + +Synopsis +~~~~~~~~ + + +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. + +:: + + mmctl channel archive [channels] [flags] + +Examples +~~~~~~~~ + +:: + + channel archive myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for archive + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_create.rst b/server/cmd/mmctl/docs/mmctl_channel_create.rst new file mode 100644 index 0000000000..364f475f22 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_create.rst @@ -0,0 +1,58 @@ +.. _mmctl_channel_create: + +mmctl channel create +-------------------- + +Create a channel + +Synopsis +~~~~~~~~ + + +Create a channel. + +:: + + mmctl channel create [flags] + +Examples +~~~~~~~~ + +:: + + channel create --team myteam --name mynewchannel --display-name "My New Channel" + channel create --team myteam --name mynewprivatechannel --display-name "My New Private Channel" --private + +Options +~~~~~~~ + +:: + + --display-name string Channel Display Name + --header string Channel header + -h, --help help for create + --name string Channel Name + --private Create a private channel. + --purpose string Channel purpose + --team string Team name or ID + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_delete.rst b/server/cmd/mmctl/docs/mmctl_channel_delete.rst new file mode 100644 index 0000000000..d61992f9a5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_delete.rst @@ -0,0 +1,53 @@ +.. _mmctl_channel_delete: + +mmctl channel delete +-------------------- + +Delete channels + +Synopsis +~~~~~~~~ + + +Permanently delete some channels. +Permanently deletes one or multiple channels along with all related information including posts from the database. + +:: + + mmctl channel delete [channels] [flags] + +Examples +~~~~~~~~ + +:: + + channel delete myteam:mychannel + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to delete the channel and a DB backup has been performed. + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_list.rst b/server/cmd/mmctl/docs/mmctl_channel_list.rst new file mode 100644 index 0000000000..c96aca0f5b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_list.rst @@ -0,0 +1,53 @@ +.. _mmctl_channel_list: + +mmctl channel list +------------------ + +List all channels on specified teams. + +Synopsis +~~~~~~~~ + + +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)'. + +:: + + mmctl channel list [teams] [flags] + +Examples +~~~~~~~~ + +:: + + channel list myteam + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_modify.rst b/server/cmd/mmctl/docs/mmctl_channel_modify.rst new file mode 100644 index 0000000000..7946000f6b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_modify.rst @@ -0,0 +1,55 @@ +.. _mmctl_channel_modify: + +mmctl channel modify +-------------------- + +Modify a channel's public/private type + +Synopsis +~~~~~~~~ + + +Change the Public/Private type of a channel. +Channel can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID. + +:: + + mmctl channel modify [channel] [flags] + +Examples +~~~~~~~~ + +:: + + channel modify myteam:mychannel --private + channel modify channelId --public + +Options +~~~~~~~ + +:: + + -h, --help help for modify + --private Convert the channel to a private channel + --public Convert the channel to a public channel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_move.rst b/server/cmd/mmctl/docs/mmctl_channel_move.rst new file mode 100644 index 0000000000..debe8ccb3b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_move.rst @@ -0,0 +1,54 @@ +.. _mmctl_channel_move: + +mmctl channel move +------------------ + +Moves channels to the specified team + +Synopsis +~~~~~~~~ + + +Moves the provided channels to the specified team. +Validates that all users in the channel belong to the target team. Incoming/Outgoing webhooks are moved along with the channel. +Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID. + +:: + + mmctl channel move [team] [channels] [flags] + +Examples +~~~~~~~~ + +:: + + channel move newteam oldteam:mychannel + +Options +~~~~~~~ + +:: + + --force Remove users that are not members of target team before moving the channel. + -h, --help help for move + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_rename.rst b/server/cmd/mmctl/docs/mmctl_channel_rename.rst new file mode 100644 index 0000000000..4f73b81ba4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_rename.rst @@ -0,0 +1,55 @@ +.. _mmctl_channel_rename: + +mmctl channel rename +-------------------- + +Rename channel + +Synopsis +~~~~~~~~ + + +Rename an existing channel. + +:: + + mmctl channel rename [channel] [flags] + +Examples +~~~~~~~~ + +:: + + 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' + +Options +~~~~~~~ + +:: + + --display-name string Channel Display Name + -h, --help help for rename + --name string Channel Name + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_search.rst b/server/cmd/mmctl/docs/mmctl_channel_search.rst new file mode 100644 index 0000000000..c3a14f143a --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_search.rst @@ -0,0 +1,55 @@ +.. _mmctl_channel_search: + +mmctl channel search +-------------------- + +Search a channel + +Synopsis +~~~~~~~~ + + +Search a channel by channel name. +Channel can be specified by team. ie. --team myteam mychannel or by team ID. + +:: + + mmctl channel search [channel] + mmctl search --team [team] [channel] [flags] + +Examples +~~~~~~~~ + +:: + + channel search mychannel + channel search --team myteam mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for search + --team string Team name or ID + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_unarchive.rst b/server/cmd/mmctl/docs/mmctl_channel_unarchive.rst new file mode 100644 index 0000000000..1375299e6f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_unarchive.rst @@ -0,0 +1,52 @@ +.. _mmctl_channel_unarchive: + +mmctl channel unarchive +----------------------- + +Unarchive some channels + +Synopsis +~~~~~~~~ + + +Unarchive a previously archived channel +Channels can be specified by [team]:[channel]. ie. myteam:mychannel or by channel ID. + +:: + + mmctl channel unarchive [channels] [flags] + +Examples +~~~~~~~~ + +:: + + channel unarchive myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for unarchive + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels + diff --git a/server/cmd/mmctl/docs/mmctl_channel_users.rst b/server/cmd/mmctl/docs/mmctl_channel_users.rst new file mode 100644 index 0000000000..53f5d3a01f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_users.rst @@ -0,0 +1,42 @@ +.. _mmctl_channel_users: + +mmctl channel users +------------------- + +Management of channel users + +Synopsis +~~~~~~~~ + + +Management of channel users + +Options +~~~~~~~ + +:: + + -h, --help help for users + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel `_ - Management of channels +* `mmctl channel users add `_ - Add users to channel +* `mmctl channel users remove `_ - Remove users from channel + diff --git a/server/cmd/mmctl/docs/mmctl_channel_users_add.rst b/server/cmd/mmctl/docs/mmctl_channel_users_add.rst new file mode 100644 index 0000000000..82844c2df3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_users_add.rst @@ -0,0 +1,51 @@ +.. _mmctl_channel_users_add: + +mmctl channel users add +----------------------- + +Add users to channel + +Synopsis +~~~~~~~~ + + +Add some users to channel + +:: + + mmctl channel users add [channel] [users] [flags] + +Examples +~~~~~~~~ + +:: + + channel users add myteam:mychannel user@example.com username + +Options +~~~~~~~ + +:: + + -h, --help help for add + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel users `_ - Management of channel users + diff --git a/server/cmd/mmctl/docs/mmctl_channel_users_remove.rst b/server/cmd/mmctl/docs/mmctl_channel_users_remove.rst new file mode 100644 index 0000000000..50adcf9c4f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_channel_users_remove.rst @@ -0,0 +1,53 @@ +.. _mmctl_channel_users_remove: + +mmctl channel users remove +-------------------------- + +Remove users from channel + +Synopsis +~~~~~~~~ + + +Remove some users from channel + +:: + + mmctl channel users remove [channel] [users] [flags] + +Examples +~~~~~~~~ + +:: + + channel users remove myteam:mychannel user@example.com username + channel users remove myteam:mychannel --all-users + +Options +~~~~~~~ + +:: + + --all-users Remove all users from the indicated channel. + -h, --help help for remove + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl channel users `_ - Management of channel users + diff --git a/server/cmd/mmctl/docs/mmctl_command.rst b/server/cmd/mmctl/docs/mmctl_command.rst new file mode 100644 index 0000000000..6771669c42 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command.rst @@ -0,0 +1,46 @@ +.. _mmctl_command: + +mmctl command +------------- + +Management of slash commands + +Synopsis +~~~~~~~~ + + +Management of slash commands + +Options +~~~~~~~ + +:: + + -h, --help help for command + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl command archive `_ - Archive a slash command +* `mmctl command create `_ - Create a custom slash command +* `mmctl command list `_ - List all commands on specified teams. +* `mmctl command modify `_ - Modify a slash command +* `mmctl command move `_ - Move a slash command to a different team +* `mmctl command show `_ - Show a custom slash command + diff --git a/server/cmd/mmctl/docs/mmctl_command_archive.rst b/server/cmd/mmctl/docs/mmctl_command_archive.rst new file mode 100644 index 0000000000..2ed690040e --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_archive.rst @@ -0,0 +1,51 @@ +.. _mmctl_command_archive: + +mmctl command archive +--------------------- + +Archive a slash command + +Synopsis +~~~~~~~~ + + +Archive a slash command. Commands can be specified by command ID. + +:: + + mmctl command archive [commandID] [flags] + +Examples +~~~~~~~~ + +:: + + command archive commandID + +Options +~~~~~~~ + +:: + + -h, --help help for archive + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_command_create.rst b/server/cmd/mmctl/docs/mmctl_command_create.rst new file mode 100644 index 0000000000..58fa21f093 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_create.rst @@ -0,0 +1,62 @@ +.. _mmctl_command_create: + +mmctl command create +-------------------- + +Create a custom slash command + +Synopsis +~~~~~~~~ + + +Create a custom slash command for the specified team. + +:: + + mmctl command create [team] [flags] + +Examples +~~~~~~~~ + +:: + + 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 + +Options +~~~~~~~ + +:: + + --autocomplete Show Command in autocomplete list + --autocompleteDesc string Short Command Description for autocomplete list + --autocompleteHint string Command Arguments displayed as help in autocomplete list + --creator string Command Creator's username, email or id (required) + --description string Command Description + -h, --help help for create + --icon string Command Icon URL + --post Use POST method for Callback URL + --response-username string Command Response Username + --title string Command Title + --trigger-word string Command Trigger Word (required) + --url string Command Callback URL (required) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_command_list.rst b/server/cmd/mmctl/docs/mmctl_command_list.rst new file mode 100644 index 0000000000..3242fa8b44 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_command_list: + +mmctl command list +------------------ + +List all commands on specified teams. + +Synopsis +~~~~~~~~ + + +List all commands on specified teams. + +:: + + mmctl command list [teams] [flags] + +Examples +~~~~~~~~ + +:: + + command list myteam + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_command_modify.rst b/server/cmd/mmctl/docs/mmctl_command_modify.rst new file mode 100644 index 0000000000..52123033bb --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_modify.rst @@ -0,0 +1,62 @@ +.. _mmctl_command_modify: + +mmctl command modify +-------------------- + +Modify a slash command + +Synopsis +~~~~~~~~ + + +Modify a slash command. Commands can be specified by command ID. + +:: + + mmctl command modify [commandID] [flags] + +Examples +~~~~~~~~ + +:: + + 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 + +Options +~~~~~~~ + +:: + + --autocomplete Show Command in autocomplete list + --autocompleteDesc string Short Command Description for autocomplete list + --autocompleteHint string Command Arguments displayed as help in autocomplete list + --creator string Command Creator's username, email or id (required) + --description string Command Description + -h, --help help for modify + --icon string Command Icon URL + --post Use POST method for Callback URL + --response-username string Command Response Username + --title string Command Title + --trigger-word string Command Trigger Word (required) + --url string Command Callback URL (required) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_command_move.rst b/server/cmd/mmctl/docs/mmctl_command_move.rst new file mode 100644 index 0000000000..1de6db8fb9 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_move.rst @@ -0,0 +1,51 @@ +.. _mmctl_command_move: + +mmctl command move +------------------ + +Move a slash command to a different team + +Synopsis +~~~~~~~~ + + +Move a slash command to a different team. Commands can be specified by command ID. + +:: + + mmctl command move [team] [commandID] [flags] + +Examples +~~~~~~~~ + +:: + + command move newteam commandID + +Options +~~~~~~~ + +:: + + -h, --help help for move + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_command_show.rst b/server/cmd/mmctl/docs/mmctl_command_show.rst new file mode 100644 index 0000000000..0b307e85bc --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_command_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_command_show: + +mmctl command show +------------------ + +Show a custom slash command + +Synopsis +~~~~~~~~ + + +Show a custom slash command. Commands can be specified by command ID. Returns command ID, team ID, trigger word, display name and creator username. + +:: + + mmctl command show [commandID] [flags] + +Examples +~~~~~~~~ + +:: + + command show commandID + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl command `_ - Management of slash commands + diff --git a/server/cmd/mmctl/docs/mmctl_completion.rst b/server/cmd/mmctl/docs/mmctl_completion.rst new file mode 100644 index 0000000000..bb1a915b6f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_completion.rst @@ -0,0 +1,42 @@ +.. _mmctl_completion: + +mmctl completion +---------------- + +Generates autocompletion scripts for bash and zsh + +Synopsis +~~~~~~~~ + + +Generates autocompletion scripts for bash and zsh + +Options +~~~~~~~ + +:: + + -h, --help help for completion + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl completion bash `_ - Generates the bash autocompletion scripts +* `mmctl completion zsh `_ - Generates the zsh autocompletion scripts + diff --git a/server/cmd/mmctl/docs/mmctl_completion_bash.rst b/server/cmd/mmctl/docs/mmctl_completion_bash.rst new file mode 100644 index 0000000000..1dc91d7db6 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_completion_bash.rst @@ -0,0 +1,49 @@ +.. _mmctl_completion_bash: + +mmctl completion bash +--------------------- + +Generates the bash autocompletion scripts + +Synopsis +~~~~~~~~ + + +To load completion, run + +. <(mmctl completion bash) + +To configure your bash shell to load completions for each session, add the above line to your ~/.bashrc + + +:: + + mmctl completion bash [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for bash + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl completion `_ - Generates autocompletion scripts for bash and zsh + diff --git a/server/cmd/mmctl/docs/mmctl_completion_zsh.rst b/server/cmd/mmctl/docs/mmctl_completion_zsh.rst new file mode 100644 index 0000000000..eb597cfdd3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_completion_zsh.rst @@ -0,0 +1,49 @@ +.. _mmctl_completion_zsh: + +mmctl completion zsh +-------------------- + +Generates the zsh autocompletion scripts + +Synopsis +~~~~~~~~ + + +To load completion, run + +. <(mmctl completion zsh) + +To configure your zsh shell to load completions for each session, add the above line to your ~/.zshrc + + +:: + + mmctl completion zsh [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for zsh + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl completion `_ - Generates autocompletion scripts for bash and zsh + diff --git a/server/cmd/mmctl/docs/mmctl_config.rst b/server/cmd/mmctl/docs/mmctl_config.rst new file mode 100644 index 0000000000..f02379ea69 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config.rst @@ -0,0 +1,49 @@ +.. _mmctl_config: + +mmctl config +------------ + +Configuration + +Synopsis +~~~~~~~~ + + +Configuration + +Options +~~~~~~~ + +:: + + -h, --help help for config + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl config edit `_ - Edit the config +* `mmctl config get `_ - Get config setting +* `mmctl config migrate `_ - Migrate existing config between backends +* `mmctl config patch `_ - Patch the config +* `mmctl config reload `_ - Reload the server configuration +* `mmctl config reset `_ - Reset config setting +* `mmctl config set `_ - Set config setting +* `mmctl config show `_ - Writes the server configuration to STDOUT +* `mmctl config subpath `_ - Update client asset loading to use the configured subpath + diff --git a/server/cmd/mmctl/docs/mmctl_config_edit.rst b/server/cmd/mmctl/docs/mmctl_config_edit.rst new file mode 100644 index 0000000000..202dc2de93 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_edit.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_edit: + +mmctl config edit +----------------- + +Edit the config + +Synopsis +~~~~~~~~ + + +Opens the editor defined in the EDITOR environment variable to modify the server's configuration and then uploads it + +:: + + mmctl config edit [flags] + +Examples +~~~~~~~~ + +:: + + config edit + +Options +~~~~~~~ + +:: + + -h, --help help for edit + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_get.rst b/server/cmd/mmctl/docs/mmctl_config_get.rst new file mode 100644 index 0000000000..db3265afd8 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_get.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_get: + +mmctl config get +---------------- + +Get config setting + +Synopsis +~~~~~~~~ + + +Gets the value of a config setting by its name in dot notation. + +:: + + mmctl config get [flags] + +Examples +~~~~~~~~ + +:: + + config get SqlSettings.DriverName + +Options +~~~~~~~ + +:: + + -h, --help help for get + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_migrate.rst b/server/cmd/mmctl/docs/mmctl_config_migrate.rst new file mode 100644 index 0000000000..9124ef308b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_migrate.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_migrate: + +mmctl config migrate +-------------------- + +Migrate existing config between backends + +Synopsis +~~~~~~~~ + + +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. + +:: + + mmctl config migrate [from_config] [to_config] [flags] + +Examples +~~~~~~~~ + +:: + + config migrate path/to/config.json "postgres://mmuser:mostest@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10" + +Options +~~~~~~~ + +:: + + -h, --help help for migrate + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_patch.rst b/server/cmd/mmctl/docs/mmctl_config_patch.rst new file mode 100644 index 0000000000..5d1195c614 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_patch.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_patch: + +mmctl config patch +------------------ + +Patch the config + +Synopsis +~~~~~~~~ + + +Patches config settings with the given config file. + +:: + + mmctl config patch [flags] + +Examples +~~~~~~~~ + +:: + + config patch /path/to/config.json + +Options +~~~~~~~ + +:: + + -h, --help help for patch + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_reload.rst b/server/cmd/mmctl/docs/mmctl_config_reload.rst new file mode 100644 index 0000000000..e05e17f5f4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_reload.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_reload: + +mmctl config reload +------------------- + +Reload the server configuration + +Synopsis +~~~~~~~~ + + +Reload the server configuration in case you want to new settings to be applied. + +:: + + mmctl config reload [flags] + +Examples +~~~~~~~~ + +:: + + config reload + +Options +~~~~~~~ + +:: + + -h, --help help for reload + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_reset.rst b/server/cmd/mmctl/docs/mmctl_config_reset.rst new file mode 100644 index 0000000000..98e88c598c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_reset.rst @@ -0,0 +1,52 @@ +.. _mmctl_config_reset: + +mmctl config reset +------------------ + +Reset config setting + +Synopsis +~~~~~~~~ + + +Resets the value of a config setting by its name in dot notation or a setting section. Accepts multiple values for array settings. + +:: + + mmctl config reset [flags] + +Examples +~~~~~~~~ + +:: + + config reset SqlSettings.DriverName LogSettings + +Options +~~~~~~~ + +:: + + --confirm confirm you really want to reset all configuration settings to its default value + -h, --help help for reset + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_set.rst b/server/cmd/mmctl/docs/mmctl_config_set.rst new file mode 100644 index 0000000000..1193d82ba3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_set.rst @@ -0,0 +1,52 @@ +.. _mmctl_config_set: + +mmctl config set +---------------- + +Set config setting + +Synopsis +~~~~~~~~ + + +Sets the value of a config setting by its name in dot notation. Accepts multiple values for array settings + +:: + + mmctl config set [flags] + +Examples +~~~~~~~~ + +:: + + config set SqlSettings.DriverName mysql + config set SqlSettings.DataSourceReplicas "replica1" "replica2" + +Options +~~~~~~~ + +:: + + -h, --help help for set + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_show.rst b/server/cmd/mmctl/docs/mmctl_config_show.rst new file mode 100644 index 0000000000..773834678b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_config_show: + +mmctl config show +----------------- + +Writes the server configuration to STDOUT + +Synopsis +~~~~~~~~ + + +Prints the server configuration and writes to STDOUT in JSON format. + +:: + + mmctl config show [flags] + +Examples +~~~~~~~~ + +:: + + config show + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_config_subpath.rst b/server/cmd/mmctl/docs/mmctl_config_subpath.rst new file mode 100644 index 0000000000..dd1ad0aa3b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_config_subpath.rst @@ -0,0 +1,60 @@ +.. _mmctl_config_subpath: + +mmctl config subpath +-------------------- + +Update client asset loading to use the configured subpath + +Synopsis +~~~~~~~~ + + +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. + +:: + + mmctl config subpath [flags] + +Examples +~~~~~~~~ + +:: + + # 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 / + +Options +~~~~~~~ + +:: + + -a, --assets-dir string directory of the Mattermost assets in the local filesystem + -h, --help help for subpath + -p, --path string path to update the assets with + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl config `_ - Configuration + diff --git a/server/cmd/mmctl/docs/mmctl_docs.rst b/server/cmd/mmctl/docs/mmctl_docs.rst new file mode 100644 index 0000000000..2f037a5061 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_docs.rst @@ -0,0 +1,45 @@ +.. _mmctl_docs: + +mmctl docs +---------- + +Generates mmctl documentation + +Synopsis +~~~~~~~~ + + +Generates mmctl documentation + +:: + + mmctl docs [flags] + +Options +~~~~~~~ + +:: + + -d, --directory string The directory where the docs would be generated in. (default "docs") + -h, --help help for docs + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/docs/mmctl_export.rst b/server/cmd/mmctl/docs/mmctl_export.rst new file mode 100644 index 0000000000..1a95a495a2 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export.rst @@ -0,0 +1,45 @@ +.. _mmctl_export: + +mmctl export +------------ + +Management of exports + +Synopsis +~~~~~~~~ + + +Management of exports + +Options +~~~~~~~ + +:: + + -h, --help help for export + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl export create `_ - Create export file +* `mmctl export delete `_ - Delete export file +* `mmctl export download `_ - Download export files +* `mmctl export job `_ - List, show and cancel export jobs +* `mmctl export list `_ - List export files + diff --git a/server/cmd/mmctl/docs/mmctl_export_create.rst b/server/cmd/mmctl/docs/mmctl_export_create.rst new file mode 100644 index 0000000000..3cb45e533c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_create.rst @@ -0,0 +1,45 @@ +.. _mmctl_export_create: + +mmctl export create +------------------- + +Create export file + +Synopsis +~~~~~~~~ + + +Create export file + +:: + + mmctl export create [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for create + --no-attachments Set to true to exclude file attachments in the export file. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export `_ - Management of exports + diff --git a/server/cmd/mmctl/docs/mmctl_export_delete.rst b/server/cmd/mmctl/docs/mmctl_export_delete.rst new file mode 100644 index 0000000000..0212421710 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_delete.rst @@ -0,0 +1,51 @@ +.. _mmctl_export_delete: + +mmctl export delete +------------------- + +Delete export file + +Synopsis +~~~~~~~~ + + +Delete export file + +:: + + mmctl export delete [exportname] [flags] + +Examples +~~~~~~~~ + +:: + + export delete export_file.zip + +Options +~~~~~~~ + +:: + + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export `_ - Management of exports + diff --git a/server/cmd/mmctl/docs/mmctl_export_download.rst b/server/cmd/mmctl/docs/mmctl_export_download.rst new file mode 100644 index 0000000000..a5f7c2e662 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_download.rst @@ -0,0 +1,56 @@ +.. _mmctl_export_download: + +mmctl export download +--------------------- + +Download export files + +Synopsis +~~~~~~~~ + + +Download export files + +:: + + mmctl export download [exportname] [filepath] [flags] + +Examples +~~~~~~~~ + +:: + + # 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 + +Options +~~~~~~~ + +:: + + -h, --help help for download + --num-retries int Number of retries to do to resume a download. (default 5) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export `_ - Management of exports + diff --git a/server/cmd/mmctl/docs/mmctl_export_job.rst b/server/cmd/mmctl/docs/mmctl_export_job.rst new file mode 100644 index 0000000000..87dd73935c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_job.rst @@ -0,0 +1,43 @@ +.. _mmctl_export_job: + +mmctl export job +---------------- + +List, show and cancel export jobs + +Synopsis +~~~~~~~~ + + +List, show and cancel export jobs + +Options +~~~~~~~ + +:: + + -h, --help help for job + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export `_ - Management of exports +* `mmctl export job cancel `_ - Cancel export job +* `mmctl export job list `_ - List export jobs +* `mmctl export job show `_ - Show export job + diff --git a/server/cmd/mmctl/docs/mmctl_export_job_cancel.rst b/server/cmd/mmctl/docs/mmctl_export_job_cancel.rst new file mode 100644 index 0000000000..e34afb7ce7 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_job_cancel.rst @@ -0,0 +1,51 @@ +.. _mmctl_export_job_cancel: + +mmctl export job cancel +----------------------- + +Cancel export job + +Synopsis +~~~~~~~~ + + +Cancel export job + +:: + + mmctl export job cancel [exportJobID] [flags] + +Examples +~~~~~~~~ + +:: + + export job cancel o98rj3ur83dp5dppfyk5yk6osy + +Options +~~~~~~~ + +:: + + -h, --help help for cancel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export job `_ - List, show and cancel export jobs + diff --git a/server/cmd/mmctl/docs/mmctl_export_job_list.rst b/server/cmd/mmctl/docs/mmctl_export_job_list.rst new file mode 100644 index 0000000000..f7f5ecd324 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_job_list.rst @@ -0,0 +1,54 @@ +.. _mmctl_export_job_list: + +mmctl export job list +--------------------- + +List export jobs + +Synopsis +~~~~~~~~ + + +List export jobs + +:: + + mmctl export job list [flags] + +Examples +~~~~~~~~ + +:: + + export job list + +Options +~~~~~~~ + +:: + + --all Fetch all export jobs. --page flag will be ignore if provided + -h, --help help for list + --page int Page number to fetch for the list of export jobs + --per-page int Number of export jobs to be fetched (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export job `_ - List, show and cancel export jobs + diff --git a/server/cmd/mmctl/docs/mmctl_export_job_show.rst b/server/cmd/mmctl/docs/mmctl_export_job_show.rst new file mode 100644 index 0000000000..3558977ce4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_job_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_export_job_show: + +mmctl export job show +--------------------- + +Show export job + +Synopsis +~~~~~~~~ + + +Show export job + +:: + + mmctl export job show [exportJobID] [flags] + +Examples +~~~~~~~~ + +:: + + export job show o98rj3ur83dp5dppfyk5yk6osy + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export job `_ - List, show and cancel export jobs + diff --git a/server/cmd/mmctl/docs/mmctl_export_list.rst b/server/cmd/mmctl/docs/mmctl_export_list.rst new file mode 100644 index 0000000000..1838961ad5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_export_list.rst @@ -0,0 +1,44 @@ +.. _mmctl_export_list: + +mmctl export list +----------------- + +List export files + +Synopsis +~~~~~~~~ + + +List export files + +:: + + mmctl export list [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl export `_ - Management of exports + diff --git a/server/cmd/mmctl/docs/mmctl_extract.rst b/server/cmd/mmctl/docs/mmctl_extract.rst new file mode 100644 index 0000000000..4591ab4d7f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_extract.rst @@ -0,0 +1,42 @@ +.. _mmctl_extract: + +mmctl extract +------------- + +Management of content extraction job. + +Synopsis +~~~~~~~~ + + +Management of content extraction job. + +Options +~~~~~~~ + +:: + + -h, --help help for extract + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl extract job `_ - List and show content extraction jobs +* `mmctl extract run `_ - Start a content extraction job. + diff --git a/server/cmd/mmctl/docs/mmctl_extract_job.rst b/server/cmd/mmctl/docs/mmctl_extract_job.rst new file mode 100644 index 0000000000..0281d13b45 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_extract_job.rst @@ -0,0 +1,42 @@ +.. _mmctl_extract_job: + +mmctl extract job +----------------- + +List and show content extraction jobs + +Synopsis +~~~~~~~~ + + +List and show content extraction jobs + +Options +~~~~~~~ + +:: + + -h, --help help for job + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl extract `_ - Management of content extraction job. +* `mmctl extract job list `_ - List content extraction jobs +* `mmctl extract job show `_ - Show extract job + diff --git a/server/cmd/mmctl/docs/mmctl_extract_job_list.rst b/server/cmd/mmctl/docs/mmctl_extract_job_list.rst new file mode 100644 index 0000000000..63e92a8299 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_extract_job_list.rst @@ -0,0 +1,54 @@ +.. _mmctl_extract_job_list: + +mmctl extract job list +---------------------- + +List content extraction jobs + +Synopsis +~~~~~~~~ + + +List content extraction jobs + +:: + + mmctl extract job list [flags] + +Examples +~~~~~~~~ + +:: + + extract job list + +Options +~~~~~~~ + +:: + + --all Fetch all extract jobs. --page flag will be ignore if provided + -h, --help help for list + --page int Page number to fetch for the list of extract jobs + --per-page int Number of extract jobs to be fetched (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl extract job `_ - List and show content extraction jobs + diff --git a/server/cmd/mmctl/docs/mmctl_extract_job_show.rst b/server/cmd/mmctl/docs/mmctl_extract_job_show.rst new file mode 100644 index 0000000000..367c9f067b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_extract_job_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_extract_job_show: + +mmctl extract job show +---------------------- + +Show extract job + +Synopsis +~~~~~~~~ + + +Show extract job + +:: + + mmctl extract job show [extractJobID] [flags] + +Examples +~~~~~~~~ + +:: + + extract job show f3d68qkkm7n8xgsfxwuo498rah + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl extract job `_ - List and show content extraction jobs + diff --git a/server/cmd/mmctl/docs/mmctl_extract_run.rst b/server/cmd/mmctl/docs/mmctl_extract_run.rst new file mode 100644 index 0000000000..f0533bfc64 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_extract_run.rst @@ -0,0 +1,53 @@ +.. _mmctl_extract_run: + +mmctl extract run +----------------- + +Start a content extraction job. + +Synopsis +~~~~~~~~ + + +Start a content extraction job. + +:: + + mmctl extract run [flags] + +Examples +~~~~~~~~ + +:: + + extract run + +Options +~~~~~~~ + +:: + + --from int The timestamp of the earliest file to extract, expressed in seconds since the unix epoch. + -h, --help help for run + --to int The timestamp of the latest file to extract, expressed in seconds since the unix epoch. Defaults to the current time. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl extract `_ - Management of content extraction job. + diff --git a/server/cmd/mmctl/docs/mmctl_group.rst b/server/cmd/mmctl/docs/mmctl_group.rst new file mode 100644 index 0000000000..2c3b339fdc --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group.rst @@ -0,0 +1,44 @@ +.. _mmctl_group: + +mmctl group +----------- + +Management of groups + +Synopsis +~~~~~~~~ + + +Management of groups + +Options +~~~~~~~ + +:: + + -h, --help help for group + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl group channel `_ - Management of channel groups +* `mmctl group list-ldap `_ - List LDAP groups +* `mmctl group team `_ - Management of team groups +* `mmctl group user `_ - Management of custom user groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_channel.rst b/server/cmd/mmctl/docs/mmctl_group_channel.rst new file mode 100644 index 0000000000..f31fbcf501 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_channel.rst @@ -0,0 +1,44 @@ +.. _mmctl_group_channel: + +mmctl group channel +------------------- + +Management of channel groups + +Synopsis +~~~~~~~~ + + +Management of channel groups + +Options +~~~~~~~ + +:: + + -h, --help help for channel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group `_ - Management of groups +* `mmctl group channel disable `_ - Disables group constrains in the specified channel +* `mmctl group channel enable `_ - Enables group constrains in the specified channel +* `mmctl group channel list `_ - List channel groups +* `mmctl group channel status `_ - Show's the group constrain status for the specified channel + diff --git a/server/cmd/mmctl/docs/mmctl_group_channel_disable.rst b/server/cmd/mmctl/docs/mmctl_group_channel_disable.rst new file mode 100644 index 0000000000..a87807fb35 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_channel_disable.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_channel_disable: + +mmctl group channel disable +--------------------------- + +Disables group constrains in the specified channel + +Synopsis +~~~~~~~~ + + +Disables group constrains in the specified channel + +:: + + mmctl group channel disable [team]:[channel] [flags] + +Examples +~~~~~~~~ + +:: + + group channel disable myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for disable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group channel `_ - Management of channel groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_channel_enable.rst b/server/cmd/mmctl/docs/mmctl_group_channel_enable.rst new file mode 100644 index 0000000000..0de6612457 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_channel_enable.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_channel_enable: + +mmctl group channel enable +-------------------------- + +Enables group constrains in the specified channel + +Synopsis +~~~~~~~~ + + +Enables group constrains in the specified channel + +:: + + mmctl group channel enable [team]:[channel] [flags] + +Examples +~~~~~~~~ + +:: + + group channel enable myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for enable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group channel `_ - Management of channel groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_channel_list.rst b/server/cmd/mmctl/docs/mmctl_group_channel_list.rst new file mode 100644 index 0000000000..4fb464f101 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_channel_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_channel_list: + +mmctl group channel list +------------------------ + +List channel groups + +Synopsis +~~~~~~~~ + + +List the groups associated with a channel + +:: + + mmctl group channel list [team]:[channel] [flags] + +Examples +~~~~~~~~ + +:: + + group channel list myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group channel `_ - Management of channel groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_channel_status.rst b/server/cmd/mmctl/docs/mmctl_group_channel_status.rst new file mode 100644 index 0000000000..057a22f97b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_channel_status.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_channel_status: + +mmctl group channel status +-------------------------- + +Show's the group constrain status for the specified channel + +Synopsis +~~~~~~~~ + + +Show's the group constrain status for the specified channel + +:: + + mmctl group channel status [team]:[channel] [flags] + +Examples +~~~~~~~~ + +:: + + group channel status myteam:mychannel + +Options +~~~~~~~ + +:: + + -h, --help help for status + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group channel `_ - Management of channel groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_list-ldap.rst b/server/cmd/mmctl/docs/mmctl_group_list-ldap.rst new file mode 100644 index 0000000000..5884421ab4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_list-ldap.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_list-ldap: + +mmctl group list-ldap +--------------------- + +List LDAP groups + +Synopsis +~~~~~~~~ + + +List LDAP groups + +:: + + mmctl group list-ldap [flags] + +Examples +~~~~~~~~ + +:: + + group list-ldap + +Options +~~~~~~~ + +:: + + -h, --help help for list-ldap + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group `_ - Management of groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_team.rst b/server/cmd/mmctl/docs/mmctl_group_team.rst new file mode 100644 index 0000000000..9ca3e89382 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_team.rst @@ -0,0 +1,44 @@ +.. _mmctl_group_team: + +mmctl group team +---------------- + +Management of team groups + +Synopsis +~~~~~~~~ + + +Management of team groups + +Options +~~~~~~~ + +:: + + -h, --help help for team + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group `_ - Management of groups +* `mmctl group team disable `_ - Disables group constrains in the specified team +* `mmctl group team enable `_ - Enables group constrains in the specified team +* `mmctl group team list `_ - List team groups +* `mmctl group team status `_ - Show's the group constrain status for the specified team + diff --git a/server/cmd/mmctl/docs/mmctl_group_team_disable.rst b/server/cmd/mmctl/docs/mmctl_group_team_disable.rst new file mode 100644 index 0000000000..e14266e1a4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_team_disable.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_team_disable: + +mmctl group team disable +------------------------ + +Disables group constrains in the specified team + +Synopsis +~~~~~~~~ + + +Disables group constrains in the specified team + +:: + + mmctl group team disable [team] [flags] + +Examples +~~~~~~~~ + +:: + + group team disable myteam + +Options +~~~~~~~ + +:: + + -h, --help help for disable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group team `_ - Management of team groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_team_enable.rst b/server/cmd/mmctl/docs/mmctl_group_team_enable.rst new file mode 100644 index 0000000000..340510ab2c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_team_enable.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_team_enable: + +mmctl group team enable +----------------------- + +Enables group constrains in the specified team + +Synopsis +~~~~~~~~ + + +Enables group constrains in the specified team + +:: + + mmctl group team enable [team] [flags] + +Examples +~~~~~~~~ + +:: + + group team enable myteam + +Options +~~~~~~~ + +:: + + -h, --help help for enable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group team `_ - Management of team groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_team_list.rst b/server/cmd/mmctl/docs/mmctl_group_team_list.rst new file mode 100644 index 0000000000..6b63c46d39 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_team_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_team_list: + +mmctl group team list +--------------------- + +List team groups + +Synopsis +~~~~~~~~ + + +List the groups associated with a team + +:: + + mmctl group team list [team] [flags] + +Examples +~~~~~~~~ + +:: + + group team list myteam + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group team `_ - Management of team groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_team_status.rst b/server/cmd/mmctl/docs/mmctl_group_team_status.rst new file mode 100644 index 0000000000..dd766ea431 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_team_status.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_team_status: + +mmctl group team status +----------------------- + +Show's the group constrain status for the specified team + +Synopsis +~~~~~~~~ + + +Show's the group constrain status for the specified team + +:: + + mmctl group team status [team] [flags] + +Examples +~~~~~~~~ + +:: + + group team status myteam + +Options +~~~~~~~ + +:: + + -h, --help help for status + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group team `_ - Management of team groups + diff --git a/server/cmd/mmctl/docs/mmctl_group_user.rst b/server/cmd/mmctl/docs/mmctl_group_user.rst new file mode 100644 index 0000000000..49e20ba6b8 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_user.rst @@ -0,0 +1,41 @@ +.. _mmctl_group_user: + +mmctl group user +---------------- + +Management of custom user groups + +Synopsis +~~~~~~~~ + + +Management of custom user groups + +Options +~~~~~~~ + +:: + + -h, --help help for user + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group `_ - Management of groups +* `mmctl group user restore `_ - Restore user group + diff --git a/server/cmd/mmctl/docs/mmctl_group_user_restore.rst b/server/cmd/mmctl/docs/mmctl_group_user_restore.rst new file mode 100644 index 0000000000..44033d10e3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_group_user_restore.rst @@ -0,0 +1,51 @@ +.. _mmctl_group_user_restore: + +mmctl group user restore +------------------------ + +Restore user group + +Synopsis +~~~~~~~~ + + +Restore deleted custom user group + +:: + + mmctl group user restore [groupname] [flags] + +Examples +~~~~~~~~ + +:: + + group user restore examplegroup + +Options +~~~~~~~ + +:: + + -h, --help help for restore + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl group user `_ - Management of custom user groups + diff --git a/server/cmd/mmctl/docs/mmctl_import.rst b/server/cmd/mmctl/docs/mmctl_import.rst new file mode 100644 index 0000000000..ab1d752de9 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import.rst @@ -0,0 +1,45 @@ +.. _mmctl_import: + +mmctl import +------------ + +Management of imports + +Synopsis +~~~~~~~~ + + +Management of imports + +Options +~~~~~~~ + +:: + + -h, --help help for import + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl import job `_ - List and show import jobs +* `mmctl import list `_ - List import files +* `mmctl import process `_ - Start an import job +* `mmctl import upload `_ - Upload import files +* `mmctl import validate `_ - Validate an import file + diff --git a/server/cmd/mmctl/docs/mmctl_import_job.rst b/server/cmd/mmctl/docs/mmctl_import_job.rst new file mode 100644 index 0000000000..447d66efb8 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_job.rst @@ -0,0 +1,42 @@ +.. _mmctl_import_job: + +mmctl import job +---------------- + +List and show import jobs + +Synopsis +~~~~~~~~ + + +List and show import jobs + +Options +~~~~~~~ + +:: + + -h, --help help for job + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import `_ - Management of imports +* `mmctl import job list `_ - List import jobs +* `mmctl import job show `_ - Show import job + diff --git a/server/cmd/mmctl/docs/mmctl_import_job_list.rst b/server/cmd/mmctl/docs/mmctl_import_job_list.rst new file mode 100644 index 0000000000..6d432f5944 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_job_list.rst @@ -0,0 +1,54 @@ +.. _mmctl_import_job_list: + +mmctl import job list +--------------------- + +List import jobs + +Synopsis +~~~~~~~~ + + +List import jobs + +:: + + mmctl import job list [flags] + +Examples +~~~~~~~~ + +:: + + import job list + +Options +~~~~~~~ + +:: + + --all Fetch all import jobs. --page flag will be ignore if provided + -h, --help help for list + --page int Page number to fetch for the list of import jobs + --per-page int Number of import jobs to be fetched (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import job `_ - List and show import jobs + diff --git a/server/cmd/mmctl/docs/mmctl_import_job_show.rst b/server/cmd/mmctl/docs/mmctl_import_job_show.rst new file mode 100644 index 0000000000..d29b74f07c --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_job_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_import_job_show: + +mmctl import job show +--------------------- + +Show import job + +Synopsis +~~~~~~~~ + + +Show import job + +:: + + mmctl import job show [importJobID] [flags] + +Examples +~~~~~~~~ + +:: + + import job show f3d68qkkm7n8xgsfxwuo498rah + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import job `_ - List and show import jobs + diff --git a/server/cmd/mmctl/docs/mmctl_import_list.rst b/server/cmd/mmctl/docs/mmctl_import_list.rst new file mode 100644 index 0000000000..5739bd8135 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_list.rst @@ -0,0 +1,49 @@ +.. _mmctl_import_list: + +mmctl import list +----------------- + +List import files + +Synopsis +~~~~~~~~ + + +List import files + +Examples +~~~~~~~~ + +:: + + import list + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import `_ - Management of imports +* `mmctl import list available `_ - List available import files +* `mmctl import list incomplete `_ - List incomplete import files uploads + diff --git a/server/cmd/mmctl/docs/mmctl_import_list_available.rst b/server/cmd/mmctl/docs/mmctl_import_list_available.rst new file mode 100644 index 0000000000..9577e20af7 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_list_available.rst @@ -0,0 +1,51 @@ +.. _mmctl_import_list_available: + +mmctl import list available +--------------------------- + +List available import files + +Synopsis +~~~~~~~~ + + +List available import files + +:: + + mmctl import list available [flags] + +Examples +~~~~~~~~ + +:: + + import list available + +Options +~~~~~~~ + +:: + + -h, --help help for available + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import list `_ - List import files + diff --git a/server/cmd/mmctl/docs/mmctl_import_list_incomplete.rst b/server/cmd/mmctl/docs/mmctl_import_list_incomplete.rst new file mode 100644 index 0000000000..d2c0bb7c8f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_list_incomplete.rst @@ -0,0 +1,51 @@ +.. _mmctl_import_list_incomplete: + +mmctl import list incomplete +---------------------------- + +List incomplete import files uploads + +Synopsis +~~~~~~~~ + + +List incomplete import files uploads + +:: + + mmctl import list incomplete [flags] + +Examples +~~~~~~~~ + +:: + + import list incomplete + +Options +~~~~~~~ + +:: + + -h, --help help for incomplete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import list `_ - List import files + diff --git a/server/cmd/mmctl/docs/mmctl_import_process.rst b/server/cmd/mmctl/docs/mmctl_import_process.rst new file mode 100644 index 0000000000..0465375a26 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_process.rst @@ -0,0 +1,51 @@ +.. _mmctl_import_process: + +mmctl import process +-------------------- + +Start an import job + +Synopsis +~~~~~~~~ + + +Start an import job + +:: + + mmctl import process [importname] [flags] + +Examples +~~~~~~~~ + +:: + + import process 35uy6cwrqfnhdx3genrhqqznxc_import.zip + +Options +~~~~~~~ + +:: + + -h, --help help for process + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import `_ - Management of imports + diff --git a/server/cmd/mmctl/docs/mmctl_import_upload.rst b/server/cmd/mmctl/docs/mmctl_import_upload.rst new file mode 100644 index 0000000000..70e8a7a591 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_upload.rst @@ -0,0 +1,53 @@ +.. _mmctl_import_upload: + +mmctl import upload +------------------- + +Upload import files + +Synopsis +~~~~~~~~ + + +Upload import files + +:: + + mmctl import upload [filepath] [flags] + +Examples +~~~~~~~~ + +:: + + import upload import_file.zip + +Options +~~~~~~~ + +:: + + -h, --help help for upload + --resume Set to true to resume an incomplete import upload. + --upload string The ID of the import upload to resume. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import `_ - Management of imports + diff --git a/server/cmd/mmctl/docs/mmctl_import_validate.rst b/server/cmd/mmctl/docs/mmctl_import_validate.rst new file mode 100644 index 0000000000..f7920236a5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_import_validate.rst @@ -0,0 +1,55 @@ +.. _mmctl_import_validate: + +mmctl import validate +--------------------- + +Validate an import file + +Synopsis +~~~~~~~~ + + +Validate an import file + +:: + + mmctl import validate [filepath] [flags] + +Examples +~~~~~~~~ + +:: + + import validate import_file.zip --team myteam --team myotherteam + +Options +~~~~~~~ + +:: + + --check-missing-teams Check for teams that are not defined but referenced in the archive + --check-server-duplicates Set to false to ignore teams, channels, and users already present on the server (default true) + -h, --help help for validate + --ignore-attachments Don't check if the attached files are present in the archive + --team stringArray Predefined team[s] to assume as already present on the destination server. Implies --check-missing-teams. The flag can be repeated + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl import `_ - Management of imports + diff --git a/server/cmd/mmctl/docs/mmctl_integrity.rst b/server/cmd/mmctl/docs/mmctl_integrity.rst new file mode 100644 index 0000000000..a3579d22ae --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_integrity.rst @@ -0,0 +1,46 @@ +.. _mmctl_integrity: + +mmctl integrity +--------------- + +Check database records integrity. + +Synopsis +~~~~~~~~ + + +Perform a relational integrity check which returns information about any orphaned record found. + +:: + + mmctl integrity [flags] + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to run a complete integrity check that may temporarily harm system performance + -h, --help help for integrity + -v, --verbose Show detailed information on integrity check results + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/docs/mmctl_ldap.rst b/server/cmd/mmctl/docs/mmctl_ldap.rst new file mode 100644 index 0000000000..255f5aa9d7 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_ldap.rst @@ -0,0 +1,42 @@ +.. _mmctl_ldap: + +mmctl ldap +---------- + +LDAP related utilities + +Synopsis +~~~~~~~~ + + +LDAP related utilities + +Options +~~~~~~~ + +:: + + -h, --help help for ldap + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl ldap idmigrate `_ - Migrate LDAP IdAttribute to new value +* `mmctl ldap sync `_ - Synchronize now + diff --git a/server/cmd/mmctl/docs/mmctl_ldap_idmigrate.rst b/server/cmd/mmctl/docs/mmctl_ldap_idmigrate.rst new file mode 100644 index 0000000000..0e312e1df5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_ldap_idmigrate.rst @@ -0,0 +1,56 @@ +.. _mmctl_ldap_idmigrate: + +mmctl ldap idmigrate +-------------------- + +Migrate LDAP IdAttribute to new value + +Synopsis +~~~~~~~~ + + +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. + +:: + + mmctl ldap idmigrate [flags] + +Examples +~~~~~~~~ + +:: + + ldap idmigrate objectGUID + +Options +~~~~~~~ + +:: + + -h, --help help for idmigrate + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl ldap `_ - LDAP related utilities + diff --git a/server/cmd/mmctl/docs/mmctl_ldap_sync.rst b/server/cmd/mmctl/docs/mmctl_ldap_sync.rst new file mode 100644 index 0000000000..1d9e6b598f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_ldap_sync.rst @@ -0,0 +1,52 @@ +.. _mmctl_ldap_sync: + +mmctl ldap sync +--------------- + +Synchronize now + +Synopsis +~~~~~~~~ + + +Synchronize all LDAP users and groups now. + +:: + + mmctl ldap sync [flags] + +Examples +~~~~~~~~ + +:: + + ldap sync + +Options +~~~~~~~ + +:: + + -h, --help help for sync + --include-removed-members Include members who left or were removed from a group-synced team/channel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl ldap `_ - LDAP related utilities + diff --git a/server/cmd/mmctl/docs/mmctl_license.rst b/server/cmd/mmctl/docs/mmctl_license.rst new file mode 100644 index 0000000000..52c1192065 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_license.rst @@ -0,0 +1,43 @@ +.. _mmctl_license: + +mmctl license +------------- + +Licensing commands + +Synopsis +~~~~~~~~ + + +Licensing commands + +Options +~~~~~~~ + +:: + + -h, --help help for license + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl license remove `_ - Remove the current license. +* `mmctl license upload `_ - Upload a license. +* `mmctl license upload-string `_ - Upload a license from a string. + diff --git a/server/cmd/mmctl/docs/mmctl_license_remove.rst b/server/cmd/mmctl/docs/mmctl_license_remove.rst new file mode 100644 index 0000000000..7fe94be496 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_license_remove.rst @@ -0,0 +1,51 @@ +.. _mmctl_license_remove: + +mmctl license remove +-------------------- + +Remove the current license. + +Synopsis +~~~~~~~~ + + +Remove the current license and leave mattermost in Team Edition. + +:: + + mmctl license remove [flags] + +Examples +~~~~~~~~ + +:: + + license remove + +Options +~~~~~~~ + +:: + + -h, --help help for remove + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl license `_ - Licensing commands + diff --git a/server/cmd/mmctl/docs/mmctl_license_upload-string.rst b/server/cmd/mmctl/docs/mmctl_license_upload-string.rst new file mode 100644 index 0000000000..295035c9f5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_license_upload-string.rst @@ -0,0 +1,51 @@ +.. _mmctl_license_upload-string: + +mmctl license upload-string +--------------------------- + +Upload a license from a string. + +Synopsis +~~~~~~~~ + + +Upload a license from a string. Replaces current license. + +:: + + mmctl license upload-string [license] [flags] + +Examples +~~~~~~~~ + +:: + + license upload-string "mylicensestring" + +Options +~~~~~~~ + +:: + + -h, --help help for upload-string + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl license `_ - Licensing commands + diff --git a/server/cmd/mmctl/docs/mmctl_license_upload.rst b/server/cmd/mmctl/docs/mmctl_license_upload.rst new file mode 100644 index 0000000000..9949aff005 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_license_upload.rst @@ -0,0 +1,51 @@ +.. _mmctl_license_upload: + +mmctl license upload +-------------------- + +Upload a license. + +Synopsis +~~~~~~~~ + + +Upload a license. Replaces current license. + +:: + + mmctl license upload [license] [flags] + +Examples +~~~~~~~~ + +:: + + license upload /path/to/license/mylicensefile.mattermost-license + +Options +~~~~~~~ + +:: + + -h, --help help for upload + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl license `_ - Licensing commands + diff --git a/server/cmd/mmctl/docs/mmctl_logs.rst b/server/cmd/mmctl/docs/mmctl_logs.rst new file mode 100644 index 0000000000..9c98a3e4cb --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_logs.rst @@ -0,0 +1,46 @@ +.. _mmctl_logs: + +mmctl logs +---------- + +Display logs in a human-readable format + +Synopsis +~~~~~~~~ + + +Display logs in a human-readable format. As the logs format depends on the server, the "--format" flag cannot be used with this command. + +:: + + mmctl logs [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for logs + -l, --logrus Use logrus for formatting. + -n, --number int Number of log lines to retrieve. (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/docs/mmctl_permissions.rst b/server/cmd/mmctl/docs/mmctl_permissions.rst new file mode 100644 index 0000000000..b317819f27 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions.rst @@ -0,0 +1,44 @@ +.. _mmctl_permissions: + +mmctl permissions +----------------- + +Management of permissions + +Synopsis +~~~~~~~~ + + +Management of permissions + +Options +~~~~~~~ + +:: + + -h, --help help for permissions + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl permissions add `_ - Add permissions to a role (EE Only) +* `mmctl permissions remove `_ - Remove permissions from a role (EE Only) +* `mmctl permissions reset `_ - Reset default permissions for role (EE Only) +* `mmctl permissions role `_ - Management of roles + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_add.rst b/server/cmd/mmctl/docs/mmctl_permissions_add.rst new file mode 100644 index 0000000000..4b15239c1d --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_add.rst @@ -0,0 +1,52 @@ +.. _mmctl_permissions_add: + +mmctl permissions add +--------------------- + +Add permissions to a role (EE Only) + +Synopsis +~~~~~~~~ + + +Add one or more permissions to an existing role (Only works in Enterprise Edition). + +:: + + mmctl permissions add [flags] + +Examples +~~~~~~~~ + +:: + + permissions add system_user list_open_teams + permissions add system_manager sysconsole_read_user_management_channels + +Options +~~~~~~~ + +:: + + -h, --help help for add + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions `_ - Management of permissions + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_remove.rst b/server/cmd/mmctl/docs/mmctl_permissions_remove.rst new file mode 100644 index 0000000000..9f7dce2bc6 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_remove.rst @@ -0,0 +1,52 @@ +.. _mmctl_permissions_remove: + +mmctl permissions remove +------------------------ + +Remove permissions from a role (EE Only) + +Synopsis +~~~~~~~~ + + +Remove one or more permissions from an existing role (Only works in Enterprise Edition). + +:: + + mmctl permissions remove [flags] + +Examples +~~~~~~~~ + +:: + + permissions remove system_user list_open_teams + permissions remove system_manager sysconsole_read_user_management_channels + +Options +~~~~~~~ + +:: + + -h, --help help for remove + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions `_ - Management of permissions + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_reset.rst b/server/cmd/mmctl/docs/mmctl_permissions_reset.rst new file mode 100644 index 0000000000..b8eb201916 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_reset.rst @@ -0,0 +1,52 @@ +.. _mmctl_permissions_reset: + +mmctl permissions reset +----------------------- + +Reset default permissions for role (EE Only) + +Synopsis +~~~~~~~~ + + +Reset the given role's permissions to the set that was originally released with + +:: + + mmctl permissions reset [flags] + +Examples +~~~~~~~~ + +:: + + # Reset the permissions of the 'system_read_only_admin' role. + $ mmctl permissions reset system_read_only_admin + +Options +~~~~~~~ + +:: + + -h, --help help for reset + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions `_ - Management of permissions + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_role.rst b/server/cmd/mmctl/docs/mmctl_permissions_role.rst new file mode 100644 index 0000000000..15b6b9f13f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_role.rst @@ -0,0 +1,43 @@ +.. _mmctl_permissions_role: + +mmctl permissions role +---------------------- + +Management of roles + +Synopsis +~~~~~~~~ + + +Management of roles + +Options +~~~~~~~ + +:: + + -h, --help help for role + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions `_ - Management of permissions +* `mmctl permissions role assign `_ - Assign users to role (EE Only) +* `mmctl permissions role show `_ - Show the role information +* `mmctl permissions role unassign `_ - Unassign users from role (EE Only) + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_role_assign.rst b/server/cmd/mmctl/docs/mmctl_permissions_role_assign.rst new file mode 100644 index 0000000000..3d2521829d --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_role_assign.rst @@ -0,0 +1,57 @@ +.. _mmctl_permissions_role_assign: + +mmctl permissions role assign +----------------------------- + +Assign users to role (EE Only) + +Synopsis +~~~~~~~~ + + +Assign users to a role by username (Only works in Enterprise Edition). + +:: + + mmctl permissions role assign [flags] + +Examples +~~~~~~~~ + +:: + + # 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 + +Options +~~~~~~~ + +:: + + -h, --help help for assign + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions role `_ - Management of roles + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_role_show.rst b/server/cmd/mmctl/docs/mmctl_permissions_role_show.rst new file mode 100644 index 0000000000..c9433e36bc --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_role_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_permissions_role_show: + +mmctl permissions role show +--------------------------- + +Show the role information + +Synopsis +~~~~~~~~ + + +Show all the information about a role. + +:: + + mmctl permissions role show [flags] + +Examples +~~~~~~~~ + +:: + + permissions show system_user + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions role `_ - Management of roles + diff --git a/server/cmd/mmctl/docs/mmctl_permissions_role_unassign.rst b/server/cmd/mmctl/docs/mmctl_permissions_role_unassign.rst new file mode 100644 index 0000000000..ed3eb949b8 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_permissions_role_unassign.rst @@ -0,0 +1,57 @@ +.. _mmctl_permissions_role_unassign: + +mmctl permissions role unassign +------------------------------- + +Unassign users from role (EE Only) + +Synopsis +~~~~~~~~ + + +Unassign users from a role by username (Only works in Enterprise Edition). + +:: + + mmctl permissions role unassign [flags] + +Examples +~~~~~~~~ + +:: + + # 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 + +Options +~~~~~~~ + +:: + + -h, --help help for unassign + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl permissions role `_ - Management of roles + diff --git a/server/cmd/mmctl/docs/mmctl_plugin.rst b/server/cmd/mmctl/docs/mmctl_plugin.rst new file mode 100644 index 0000000000..2fcc8d8837 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin.rst @@ -0,0 +1,47 @@ +.. _mmctl_plugin: + +mmctl plugin +------------ + +Management of plugins + +Synopsis +~~~~~~~~ + + +Management of plugins + +Options +~~~~~~~ + +:: + + -h, --help help for plugin + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl plugin add `_ - Add plugins +* `mmctl plugin delete `_ - Delete plugins +* `mmctl plugin disable `_ - Disable plugins +* `mmctl plugin enable `_ - Enable plugins +* `mmctl plugin install-url `_ - Install plugin from url +* `mmctl plugin list `_ - List plugins +* `mmctl plugin marketplace `_ - Management of marketplace plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_add.rst b/server/cmd/mmctl/docs/mmctl_plugin_add.rst new file mode 100644 index 0000000000..8c273a6882 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_add.rst @@ -0,0 +1,52 @@ +.. _mmctl_plugin_add: + +mmctl plugin add +---------------- + +Add plugins + +Synopsis +~~~~~~~~ + + +Add plugins to your Mattermost server. + +:: + + mmctl plugin add [plugins] [flags] + +Examples +~~~~~~~~ + +:: + + plugin add hovercardexample.tar.gz pluginexample.tar.gz + +Options +~~~~~~~ + +:: + + -f, --force overwrite a previously installed plugin with the same ID, if any + -h, --help help for add + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_delete.rst b/server/cmd/mmctl/docs/mmctl_plugin_delete.rst new file mode 100644 index 0000000000..196cc87df9 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_delete.rst @@ -0,0 +1,51 @@ +.. _mmctl_plugin_delete: + +mmctl plugin delete +------------------- + +Delete plugins + +Synopsis +~~~~~~~~ + + +Delete previously uploaded plugins from your Mattermost server. + +:: + + mmctl plugin delete [plugins] [flags] + +Examples +~~~~~~~~ + +:: + + plugin delete hovercardexample pluginexample + +Options +~~~~~~~ + +:: + + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_disable.rst b/server/cmd/mmctl/docs/mmctl_plugin_disable.rst new file mode 100644 index 0000000000..554dbbb47a --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_disable.rst @@ -0,0 +1,51 @@ +.. _mmctl_plugin_disable: + +mmctl plugin disable +-------------------- + +Disable plugins + +Synopsis +~~~~~~~~ + + +Disable plugins. Disabled plugins are immediately removed from the user interface and logged out of all sessions. + +:: + + mmctl plugin disable [plugins] [flags] + +Examples +~~~~~~~~ + +:: + + plugin disable hovercardexample pluginexample + +Options +~~~~~~~ + +:: + + -h, --help help for disable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_enable.rst b/server/cmd/mmctl/docs/mmctl_plugin_enable.rst new file mode 100644 index 0000000000..4daa359737 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_enable.rst @@ -0,0 +1,51 @@ +.. _mmctl_plugin_enable: + +mmctl plugin enable +------------------- + +Enable plugins + +Synopsis +~~~~~~~~ + + +Enable plugins for use on your Mattermost server. + +:: + + mmctl plugin enable [plugins] [flags] + +Examples +~~~~~~~~ + +:: + + plugin enable hovercardexample pluginexample + +Options +~~~~~~~ + +:: + + -h, --help help for enable + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_install-url.rst b/server/cmd/mmctl/docs/mmctl_plugin_install-url.rst new file mode 100644 index 0000000000..6928bf706b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_install-url.rst @@ -0,0 +1,56 @@ +.. _mmctl_plugin_install-url: + +mmctl plugin install-url +------------------------ + +Install plugin from url + +Synopsis +~~~~~~~~ + + +Supply one or multiple URLs to plugins compressed in a .tar.gz file. Plugins must be enabled in the server's config settings + +:: + + mmctl plugin install-url ... [flags] + +Examples +~~~~~~~~ + +:: + + # 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 + +Options +~~~~~~~ + +:: + + -f, --force overwrite a previously installed plugin with the same ID, if any + -h, --help help for install-url + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_list.rst b/server/cmd/mmctl/docs/mmctl_plugin_list.rst new file mode 100644 index 0000000000..bd53c1b030 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_plugin_list: + +mmctl plugin list +----------------- + +List plugins + +Synopsis +~~~~~~~~ + + +List all enabled and disabled plugins installed on your Mattermost server. + +:: + + mmctl plugin list [flags] + +Examples +~~~~~~~~ + +:: + + plugin list + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_marketplace.rst b/server/cmd/mmctl/docs/mmctl_plugin_marketplace.rst new file mode 100644 index 0000000000..fc52ca7f78 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_marketplace.rst @@ -0,0 +1,42 @@ +.. _mmctl_plugin_marketplace: + +mmctl plugin marketplace +------------------------ + +Management of marketplace plugins + +Synopsis +~~~~~~~~ + + +Management of marketplace plugins + +Options +~~~~~~~ + +:: + + -h, --help help for marketplace + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin `_ - Management of plugins +* `mmctl plugin marketplace install `_ - Install a plugin from the marketplace +* `mmctl plugin marketplace list `_ - List marketplace plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_marketplace_install.rst b/server/cmd/mmctl/docs/mmctl_plugin_marketplace_install.rst new file mode 100644 index 0000000000..423a3b0bc1 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_marketplace_install.rst @@ -0,0 +1,51 @@ +.. _mmctl_plugin_marketplace_install: + +mmctl plugin marketplace install +-------------------------------- + +Install a plugin from the marketplace + +Synopsis +~~~~~~~~ + + +Installs a plugin listed in the marketplace server + +:: + + mmctl plugin marketplace install [flags] + +Examples +~~~~~~~~ + +:: + + plugin marketplace install jitsi + +Options +~~~~~~~ + +:: + + -h, --help help for install + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin marketplace `_ - Management of marketplace plugins + diff --git a/server/cmd/mmctl/docs/mmctl_plugin_marketplace_list.rst b/server/cmd/mmctl/docs/mmctl_plugin_marketplace_list.rst new file mode 100644 index 0000000000..57750d7c3b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_plugin_marketplace_list.rst @@ -0,0 +1,66 @@ +.. _mmctl_plugin_marketplace_list: + +mmctl plugin marketplace list +----------------------------- + +List marketplace plugins + +Synopsis +~~~~~~~~ + + +Gets all plugins from the marketplace server, merging data from locally installed plugins as well as prepackaged plugins shipped with the server + +:: + + mmctl plugin marketplace list [flags] + +Examples +~~~~~~~~ + +:: + + # 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 + +Options +~~~~~~~ + +:: + + --all Fetch all plugins. --page flag will be ignore if provided + --filter string Filter plugins by ID, name or description + -h, --help help for list + --local-only Only retrieve local plugins + --page int Page number to fetch for the list of users + --per-page int Number of users to be fetched (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl plugin marketplace `_ - Management of marketplace plugins + diff --git a/server/cmd/mmctl/docs/mmctl_post.rst b/server/cmd/mmctl/docs/mmctl_post.rst new file mode 100644 index 0000000000..af35928122 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_post.rst @@ -0,0 +1,42 @@ +.. _mmctl_post: + +mmctl post +---------- + +Management of posts + +Synopsis +~~~~~~~~ + + +Management of posts + +Options +~~~~~~~ + +:: + + -h, --help help for post + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl post create `_ - Create a post +* `mmctl post list `_ - List posts for a channel + diff --git a/server/cmd/mmctl/docs/mmctl_post_create.rst b/server/cmd/mmctl/docs/mmctl_post_create.rst new file mode 100644 index 0000000000..9d8617c73f --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_post_create.rst @@ -0,0 +1,53 @@ +.. _mmctl_post_create: + +mmctl post create +----------------- + +Create a post + +Synopsis +~~~~~~~~ + + +Create a post + +:: + + mmctl post create [flags] + +Examples +~~~~~~~~ + +:: + + post create myteam:mychannel --message "some text for the post" + +Options +~~~~~~~ + +:: + + -h, --help help for create + -m, --message string Message for the post + -r, --reply-to string Post id to reply to + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl post `_ - Management of posts + diff --git a/server/cmd/mmctl/docs/mmctl_post_list.rst b/server/cmd/mmctl/docs/mmctl_post_list.rst new file mode 100644 index 0000000000..644d4ff754 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_post_list.rst @@ -0,0 +1,56 @@ +.. _mmctl_post_list: + +mmctl post list +--------------- + +List posts for a channel + +Synopsis +~~~~~~~~ + + +List posts for a channel + +:: + + mmctl post list [flags] + +Examples +~~~~~~~~ + +:: + + post list myteam:mychannel + post list myteam:mychannel --number 20 + +Options +~~~~~~~ + +:: + + -f, --follow Output appended data as new messages are posted to the channel + -h, --help help for list + -n, --number int Number of messages to list (default 20) + -i, --show-ids Show posts ids + -s, --since string List messages posted after a certain time (ISO 8601) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl post `_ - Management of posts + diff --git a/server/cmd/mmctl/docs/mmctl_roles.rst b/server/cmd/mmctl/docs/mmctl_roles.rst new file mode 100644 index 0000000000..7274893240 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_roles.rst @@ -0,0 +1,42 @@ +.. _mmctl_roles: + +mmctl roles +----------- + +Manage user roles + +Synopsis +~~~~~~~~ + + +Manage user roles + +Options +~~~~~~~ + +:: + + -h, --help help for roles + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl roles member `_ - Remove system admin privileges +* `mmctl roles system-admin `_ - Set a user as system admin + diff --git a/server/cmd/mmctl/docs/mmctl_roles_member.rst b/server/cmd/mmctl/docs/mmctl_roles_member.rst new file mode 100644 index 0000000000..4b90bf03e5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_roles_member.rst @@ -0,0 +1,55 @@ +.. _mmctl_roles_member: + +mmctl roles member +------------------ + +Remove system admin privileges + +Synopsis +~~~~~~~~ + + +Remove system admin privileges from some users. + +:: + + mmctl roles member [users] [flags] + +Examples +~~~~~~~~ + +:: + + # You can remove admin privileges from one user + $ mmctl roles member john_doe + + # Or demote multiple users at the same time + $ mmctl roles member john_doe jane_doe + +Options +~~~~~~~ + +:: + + -h, --help help for member + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl roles `_ - Manage user roles + diff --git a/server/cmd/mmctl/docs/mmctl_roles_system-admin.rst b/server/cmd/mmctl/docs/mmctl_roles_system-admin.rst new file mode 100644 index 0000000000..426d1a4605 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_roles_system-admin.rst @@ -0,0 +1,55 @@ +.. _mmctl_roles_system-admin: + +mmctl roles system-admin +------------------------ + +Set a user as system admin + +Synopsis +~~~~~~~~ + + +Make some users system admins. + +:: + + mmctl roles system-admin [users] [flags] + +Examples +~~~~~~~~ + +:: + + # You can make one user a sysadmin + $ mmctl roles system-admin john_doe + + # Or promote multiple users at the same time + $ mmctl roles system-admin john_doe jane_doe + +Options +~~~~~~~ + +:: + + -h, --help help for system-admin + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl roles `_ - Manage user roles + diff --git a/server/cmd/mmctl/docs/mmctl_saml.rst b/server/cmd/mmctl/docs/mmctl_saml.rst new file mode 100644 index 0000000000..c032ec483d --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_saml.rst @@ -0,0 +1,41 @@ +.. _mmctl_saml: + +mmctl saml +---------- + +SAML related utilities + +Synopsis +~~~~~~~~ + + +SAML related utilities + +Options +~~~~~~~ + +:: + + -h, --help help for saml + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl saml auth-data-reset `_ - Reset AuthData field to Email + diff --git a/server/cmd/mmctl/docs/mmctl_saml_auth-data-reset.rst b/server/cmd/mmctl/docs/mmctl_saml_auth-data-reset.rst new file mode 100644 index 0000000000..4990261589 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_saml_auth-data-reset.rst @@ -0,0 +1,65 @@ +.. _mmctl_saml_auth-data-reset: + +mmctl saml auth-data-reset +-------------------------- + +Reset AuthData field to Email + +Synopsis +~~~~~~~~ + + +Resets the AuthData field for SAML users to their email. Run this utility after setting the 'id' SAML attribute to an empty value. + +:: + + mmctl saml auth-data-reset [flags] + +Examples +~~~~~~~~ + +:: + + # Reset all SAML users' AuthData field to their email, including deleted users + $ mmctl saml auth-data-reset --include-deleted + + # Show how many users would be affected by the reset + $ mmctl saml auth-data-reset --dry-run + + # Skip confirmation for resetting the AuthData + $ mmctl saml auth-data-reset -y + + # Only reset the AuthData for the following SAML users + $ mmctl saml auth-data-reset --users userid1,userid2 + +Options +~~~~~~~ + +:: + + --dry-run Dry run only + -h, --help help for auth-data-reset + --include-deleted Include deleted users + --users strings Comma-separated list of user IDs to which the operation will be applied + -y, --yes Skip confirmation + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl saml `_ - SAML related utilities + diff --git a/server/cmd/mmctl/docs/mmctl_sampledata.rst b/server/cmd/mmctl/docs/mmctl_sampledata.rst new file mode 100644 index 0000000000..09078c6bd0 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_sampledata.rst @@ -0,0 +1,79 @@ +.. _mmctl_sampledata: + +mmctl sampledata +---------------- + +Generate sample data + +Synopsis +~~~~~~~~ + + +Generate a sample data file and store it locally, or directly import it to the remote server + +:: + + mmctl sampledata [flags] + +Examples +~~~~~~~~ + +:: + + # you can create a sampledata file and store it locally + $ mmctl sampledata --bulk sampledata-file.jsonl + + # or you can simply print it to the stdout + $ mmctl sampledata --bulk - + + # the amount of entities to create can be customized + $ mmctl sampledata -t 7 -u 20 -g 4 + + # the sampledata file can be directly imported in the remote server by not specifying a --bulk flag + $ mmctl sampledata + + # and the sample users can be created with profile pictures + $ mmctl sampledata --profile-images ./images/profiles + +Options +~~~~~~~ + +:: + + -b, --bulk string Optional. Path to write a JSONL bulk file instead of uploading into the remote server. + --channel-memberships int The number of sample channel memberships per user in a team. (default 5) + --channels-per-team int The number of sample channels per team. (default 10) + --deactivated-users int The number of deactivated users. + --direct-channels int The number of sample direct message channels. (default 30) + --group-channels int The number of sample group message channels. (default 15) + -g, --guests int The number of sample guests. (default 1) + -h, --help help for sampledata + --posts-per-channel int The number of sample post per channel. (default 100) + --posts-per-direct-channel int The number of sample posts per direct message channel. (default 15) + --posts-per-group-channel int The number of sample posts per group message channel. (default 30) + --profile-images string Optional. Path to folder with images to randomly pick as user profile image. + -s, --seed int Seed used for generating the random data (Different seeds generate different data). (default 1) + --team-memberships int The number of sample team memberships per user. (default 2) + -t, --teams int The number of sample teams. (default 2) + -u, --users int The number of sample users. (default 15) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/docs/mmctl_system.rst b/server/cmd/mmctl/docs/mmctl_system.rst new file mode 100644 index 0000000000..1cc9a67235 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system.rst @@ -0,0 +1,45 @@ +.. _mmctl_system: + +mmctl system +------------ + +System management + +Synopsis +~~~~~~~~ + + +System management commands for interacting with the server state and configuration. + +Options +~~~~~~~ + +:: + + -h, --help help for system + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl system clearbusy `_ - Clears the busy state +* `mmctl system getbusy `_ - Get the current busy state +* `mmctl system setbusy `_ - Set the busy state to true +* `mmctl system status `_ - Prints the status of the server +* `mmctl system version `_ - Prints the remote server version + diff --git a/server/cmd/mmctl/docs/mmctl_system_clearbusy.rst b/server/cmd/mmctl/docs/mmctl_system_clearbusy.rst new file mode 100644 index 0000000000..df3f6b2cbd --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system_clearbusy.rst @@ -0,0 +1,51 @@ +.. _mmctl_system_clearbusy: + +mmctl system clearbusy +---------------------- + +Clears the busy state + +Synopsis +~~~~~~~~ + + +Clear the busy state, which re-enables non-critical services. + +:: + + mmctl system clearbusy [flags] + +Examples +~~~~~~~~ + +:: + + system clearbusy + +Options +~~~~~~~ + +:: + + -h, --help help for clearbusy + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl system `_ - System management + diff --git a/server/cmd/mmctl/docs/mmctl_system_getbusy.rst b/server/cmd/mmctl/docs/mmctl_system_getbusy.rst new file mode 100644 index 0000000000..b5a58dfcc8 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system_getbusy.rst @@ -0,0 +1,51 @@ +.. _mmctl_system_getbusy: + +mmctl system getbusy +-------------------- + +Get the current busy state + +Synopsis +~~~~~~~~ + + +Gets the server busy state (high load) and timestamp corresponding to when the server busy flag will be automatically cleared. + +:: + + mmctl system getbusy [flags] + +Examples +~~~~~~~~ + +:: + + system getbusy + +Options +~~~~~~~ + +:: + + -h, --help help for getbusy + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl system `_ - System management + diff --git a/server/cmd/mmctl/docs/mmctl_system_setbusy.rst b/server/cmd/mmctl/docs/mmctl_system_setbusy.rst new file mode 100644 index 0000000000..e72041de02 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system_setbusy.rst @@ -0,0 +1,52 @@ +.. _mmctl_system_setbusy: + +mmctl system setbusy +-------------------- + +Set the busy state to true + +Synopsis +~~~~~~~~ + + +Set the busy state to true for the specified number of seconds, which disables non-critical services. + +:: + + mmctl system setbusy -s [seconds] [flags] + +Examples +~~~~~~~~ + +:: + + system setbusy -s 3600 + +Options +~~~~~~~ + +:: + + -h, --help help for setbusy + -s, --seconds uint Number of seconds until server is automatically marked as not busy. (default 3600) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl system `_ - System management + diff --git a/server/cmd/mmctl/docs/mmctl_system_status.rst b/server/cmd/mmctl/docs/mmctl_system_status.rst new file mode 100644 index 0000000000..ed98e4e2a4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system_status.rst @@ -0,0 +1,51 @@ +.. _mmctl_system_status: + +mmctl system status +------------------- + +Prints the status of the server + +Synopsis +~~~~~~~~ + + +Prints the server status calculated using several basic server healthchecks + +:: + + mmctl system status [flags] + +Examples +~~~~~~~~ + +:: + + system status + +Options +~~~~~~~ + +:: + + -h, --help help for status + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl system `_ - System management + diff --git a/server/cmd/mmctl/docs/mmctl_system_version.rst b/server/cmd/mmctl/docs/mmctl_system_version.rst new file mode 100644 index 0000000000..8a4cefe68e --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_system_version.rst @@ -0,0 +1,51 @@ +.. _mmctl_system_version: + +mmctl system version +-------------------- + +Prints the remote server version + +Synopsis +~~~~~~~~ + + +Prints the server version of the currently connected Mattermost instance + +:: + + mmctl system version [flags] + +Examples +~~~~~~~~ + +:: + + system version + +Options +~~~~~~~ + +:: + + -h, --help help for version + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl system `_ - System management + diff --git a/server/cmd/mmctl/docs/mmctl_team.rst b/server/cmd/mmctl/docs/mmctl_team.rst new file mode 100644 index 0000000000..933251ac55 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team.rst @@ -0,0 +1,49 @@ +.. _mmctl_team: + +mmctl team +---------- + +Management of teams + +Synopsis +~~~~~~~~ + + +Management of teams + +Options +~~~~~~~ + +:: + + -h, --help help for team + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl team archive `_ - Archive teams +* `mmctl team create `_ - Create a team +* `mmctl team delete `_ - Delete teams +* `mmctl team list `_ - List all teams +* `mmctl team modify `_ - Modify teams +* `mmctl team rename `_ - Rename team +* `mmctl team restore `_ - Restore teams +* `mmctl team search `_ - Search for teams +* `mmctl team users `_ - Management of team users + diff --git a/server/cmd/mmctl/docs/mmctl_team_archive.rst b/server/cmd/mmctl/docs/mmctl_team_archive.rst new file mode 100644 index 0000000000..c9ae6b2fbc --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_archive.rst @@ -0,0 +1,53 @@ +.. _mmctl_team_archive: + +mmctl team archive +------------------ + +Archive teams + +Synopsis +~~~~~~~~ + + +Archive some teams. +Archives a team along with all related information including posts from the database. + +:: + + mmctl team archive [teams] [flags] + +Examples +~~~~~~~~ + +:: + + team archive myteam + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to archive the team and a DB backup has been performed. + -h, --help help for archive + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_create.rst b/server/cmd/mmctl/docs/mmctl_team_create.rst new file mode 100644 index 0000000000..2de17123cd --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_create.rst @@ -0,0 +1,56 @@ +.. _mmctl_team_create: + +mmctl team create +----------------- + +Create a team + +Synopsis +~~~~~~~~ + + +Create a team. + +:: + + mmctl team create [flags] + +Examples +~~~~~~~~ + +:: + + team create --name mynewteam --display-name "My New Team" + team create --name private --display-name "My New Private Team" --private + +Options +~~~~~~~ + +:: + + --display-name string Team Display Name + --email string Administrator Email (anyone with this email is automatically a team admin) + -h, --help help for create + --name string Team Name + --private Create a private team. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_delete.rst b/server/cmd/mmctl/docs/mmctl_team_delete.rst new file mode 100644 index 0000000000..4ad2d15114 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_delete.rst @@ -0,0 +1,53 @@ +.. _mmctl_team_delete: + +mmctl team delete +----------------- + +Delete teams + +Synopsis +~~~~~~~~ + + +Permanently delete some teams. +Permanently deletes a team along with all related information including posts from the database. + +:: + + mmctl team delete [teams] [flags] + +Examples +~~~~~~~~ + +:: + + team delete myteam + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to delete the team and a DB backup has been performed. + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_list.rst b/server/cmd/mmctl/docs/mmctl_team_list.rst new file mode 100644 index 0000000000..fe24df298a --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_team_list: + +mmctl team list +--------------- + +List all teams + +Synopsis +~~~~~~~~ + + +List all teams on the server. + +:: + + mmctl team list [flags] + +Examples +~~~~~~~~ + +:: + + team list + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_modify.rst b/server/cmd/mmctl/docs/mmctl_team_modify.rst new file mode 100644 index 0000000000..70516432c4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_modify.rst @@ -0,0 +1,53 @@ +.. _mmctl_team_modify: + +mmctl team modify +----------------- + +Modify teams + +Synopsis +~~~~~~~~ + + +Modify teams' privacy setting to public or private + +:: + + mmctl team modify [teams] [flag] [flags] + +Examples +~~~~~~~~ + +:: + + team modify myteam --private + +Options +~~~~~~~ + +:: + + -h, --help help for modify + --private Modify team to be private. + --public Modify team to be public. + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_rename.rst b/server/cmd/mmctl/docs/mmctl_team_rename.rst new file mode 100644 index 0000000000..debc538b29 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_rename.rst @@ -0,0 +1,52 @@ +.. _mmctl_team_rename: + +mmctl team rename +----------------- + +Rename team + +Synopsis +~~~~~~~~ + + +Rename an existing team + +:: + + mmctl team rename [team] [flags] + +Examples +~~~~~~~~ + +:: + + team rename old-team --display-name 'New Display Name' + +Options +~~~~~~~ + +:: + + --display-name string Team Display Name + -h, --help help for rename + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_restore.rst b/server/cmd/mmctl/docs/mmctl_team_restore.rst new file mode 100644 index 0000000000..e6e4ba5232 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_restore.rst @@ -0,0 +1,51 @@ +.. _mmctl_team_restore: + +mmctl team restore +------------------ + +Restore teams + +Synopsis +~~~~~~~~ + + +Restores archived teams. + +:: + + mmctl team restore [teams] [flags] + +Examples +~~~~~~~~ + +:: + + team restore myteam + +Options +~~~~~~~ + +:: + + -h, --help help for restore + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_search.rst b/server/cmd/mmctl/docs/mmctl_team_search.rst new file mode 100644 index 0000000000..61e22a798e --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_search.rst @@ -0,0 +1,51 @@ +.. _mmctl_team_search: + +mmctl team search +----------------- + +Search for teams + +Synopsis +~~~~~~~~ + + +Search for teams based on name + +:: + + mmctl team search [teams] [flags] + +Examples +~~~~~~~~ + +:: + + team search team1 + +Options +~~~~~~~ + +:: + + -h, --help help for search + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams + diff --git a/server/cmd/mmctl/docs/mmctl_team_users.rst b/server/cmd/mmctl/docs/mmctl_team_users.rst new file mode 100644 index 0000000000..bd991da030 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_users.rst @@ -0,0 +1,42 @@ +.. _mmctl_team_users: + +mmctl team users +---------------- + +Management of team users + +Synopsis +~~~~~~~~ + + +Management of team users + +Options +~~~~~~~ + +:: + + -h, --help help for users + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team `_ - Management of teams +* `mmctl team users add `_ - Add users to team +* `mmctl team users remove `_ - Remove users from team + diff --git a/server/cmd/mmctl/docs/mmctl_team_users_add.rst b/server/cmd/mmctl/docs/mmctl_team_users_add.rst new file mode 100644 index 0000000000..b489253bab --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_users_add.rst @@ -0,0 +1,51 @@ +.. _mmctl_team_users_add: + +mmctl team users add +-------------------- + +Add users to team + +Synopsis +~~~~~~~~ + + +Add some users to team + +:: + + mmctl team users add [team] [users] [flags] + +Examples +~~~~~~~~ + +:: + + team users add myteam user@example.com username + +Options +~~~~~~~ + +:: + + -h, --help help for add + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team users `_ - Management of team users + diff --git a/server/cmd/mmctl/docs/mmctl_team_users_remove.rst b/server/cmd/mmctl/docs/mmctl_team_users_remove.rst new file mode 100644 index 0000000000..16e492f3b0 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_team_users_remove.rst @@ -0,0 +1,51 @@ +.. _mmctl_team_users_remove: + +mmctl team users remove +----------------------- + +Remove users from team + +Synopsis +~~~~~~~~ + + +Remove some users from team + +:: + + mmctl team users remove [team] [users] [flags] + +Examples +~~~~~~~~ + +:: + + team users remove myteam user@example.com username + +Options +~~~~~~~ + +:: + + -h, --help help for remove + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl team users `_ - Management of team users + diff --git a/server/cmd/mmctl/docs/mmctl_token.rst b/server/cmd/mmctl/docs/mmctl_token.rst new file mode 100644 index 0000000000..40823147fa --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_token.rst @@ -0,0 +1,43 @@ +.. _mmctl_token: + +mmctl token +----------- + +manage users' access tokens + +Synopsis +~~~~~~~~ + + +manage users' access tokens + +Options +~~~~~~~ + +:: + + -h, --help help for token + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl token generate `_ - Generate token for a user +* `mmctl token list `_ - List users tokens +* `mmctl token revoke `_ - Revoke tokens for a user + diff --git a/server/cmd/mmctl/docs/mmctl_token_generate.rst b/server/cmd/mmctl/docs/mmctl_token_generate.rst new file mode 100644 index 0000000000..cb31a4292b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_token_generate.rst @@ -0,0 +1,51 @@ +.. _mmctl_token_generate: + +mmctl token generate +-------------------- + +Generate token for a user + +Synopsis +~~~~~~~~ + + +Generate token for a user + +:: + + mmctl token generate [user] [description] [flags] + +Examples +~~~~~~~~ + +:: + + generate testuser test-token + +Options +~~~~~~~ + +:: + + -h, --help help for generate + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl token `_ - manage users' access tokens + diff --git a/server/cmd/mmctl/docs/mmctl_token_list.rst b/server/cmd/mmctl/docs/mmctl_token_list.rst new file mode 100644 index 0000000000..f15ebaf239 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_token_list.rst @@ -0,0 +1,56 @@ +.. _mmctl_token_list: + +mmctl token list +---------------- + +List users tokens + +Synopsis +~~~~~~~~ + + +List the tokens of a user + +:: + + mmctl token list [user] [flags] + +Examples +~~~~~~~~ + +:: + + user tokens testuser + +Options +~~~~~~~ + +:: + + --active List only active tokens (default true) + --all Fetch all tokens. --page flag will be ignore if provided + -h, --help help for list + --inactive List only inactive tokens + --page int Page number to fetch for the list of users + --per-page int Number of users to be fetched (default 200) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl token `_ - manage users' access tokens + diff --git a/server/cmd/mmctl/docs/mmctl_token_revoke.rst b/server/cmd/mmctl/docs/mmctl_token_revoke.rst new file mode 100644 index 0000000000..32e821f1a3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_token_revoke.rst @@ -0,0 +1,51 @@ +.. _mmctl_token_revoke: + +mmctl token revoke +------------------ + +Revoke tokens for a user + +Synopsis +~~~~~~~~ + + +Revoke tokens for a user + +:: + + mmctl token revoke [token-ids] [flags] + +Examples +~~~~~~~~ + +:: + + revoke testuser test-token-id + +Options +~~~~~~~ + +:: + + -h, --help help for revoke + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl token `_ - manage users' access tokens + diff --git a/server/cmd/mmctl/docs/mmctl_user.rst b/server/cmd/mmctl/docs/mmctl_user.rst new file mode 100644 index 0000000000..383e9886f3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user.rst @@ -0,0 +1,58 @@ +.. _mmctl_user: + +mmctl user +---------- + +Management of users + +Synopsis +~~~~~~~~ + + +Management of users + +Options +~~~~~~~ + +:: + + -h, --help help for user + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl user activate `_ - Activate users +* `mmctl user change-password `_ - Changes a user's password +* `mmctl user convert `_ - Convert users to bots, or a bot to a user +* `mmctl user create `_ - Create a user +* `mmctl user deactivate `_ - Deactivate users +* `mmctl user delete `_ - Delete users +* `mmctl user deleteall `_ - Delete all users and all posts. Local command only. +* `mmctl user demote `_ - Demote users to guests +* `mmctl user email `_ - Change email of the user +* `mmctl user invite `_ - Send user an email invite to a team. +* `mmctl user list `_ - List users +* `mmctl user migrate-auth `_ - Mass migrate user accounts authentication type +* `mmctl user promote `_ - Promote guests to users +* `mmctl user reset-password `_ - Send users an email to reset their password +* `mmctl user resetmfa `_ - Turn off MFA +* `mmctl user search `_ - Search for users +* `mmctl user username `_ - Change username of the user +* `mmctl user verify `_ - Mark user's email as verified + diff --git a/server/cmd/mmctl/docs/mmctl_user_activate.rst b/server/cmd/mmctl/docs/mmctl_user_activate.rst new file mode 100644 index 0000000000..3374146687 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_activate.rst @@ -0,0 +1,52 @@ +.. _mmctl_user_activate: + +mmctl user activate +------------------- + +Activate users + +Synopsis +~~~~~~~~ + + +Activate users that have been deactivated. + +:: + + mmctl user activate [emails, usernames, userIds] [flags] + +Examples +~~~~~~~~ + +:: + + user activate user@example.com + user activate username + +Options +~~~~~~~ + +:: + + -h, --help help for activate + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_change-password.rst b/server/cmd/mmctl/docs/mmctl_user_change-password.rst new file mode 100644 index 0000000000..e5c2051f89 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_change-password.rst @@ -0,0 +1,68 @@ +.. _mmctl_user_change-password: + +mmctl user change-password +-------------------------- + +Changes a user's password + +Synopsis +~~~~~~~~ + + +Changes the password of a user by a new one provided. If the user is changing their own password, the flag --current must indicate the current password. The flag --hashed can be used to indicate that the new password has been introduced already hashed + +:: + + mmctl user change-password [flags] + +Examples +~~~~~~~~ + +:: + + # if you have system permissions, you can change other user's passwords + $ mmctl user change-password john_doe --password new-password + + # if you are changing your own password, you need to provide the current one + $ mmctl user change-password my-username --current current-password --password new-password + + # you can ommit these flags to introduce them interactively + $ mmctl user change-password my-username + Are you changing your own password? (YES/NO): YES + Current password: + New password: + + # if you have system permissions, you can update the password with the already hashed new + # password. The hashing method should be the same that the server uses internally + $ mmctl user change-password john_doe --password HASHED_PASSWORD --hashed + +Options +~~~~~~~ + +:: + + -c, --current string The current password of the user. Use only if changing your own password + --hashed The supplied password is already hashed + -h, --help help for change-password + -p, --password string The new password for the user + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_convert.rst b/server/cmd/mmctl/docs/mmctl_user_convert.rst new file mode 100644 index 0000000000..4d5adfe1b4 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_convert.rst @@ -0,0 +1,68 @@ +.. _mmctl_user_convert: + +mmctl user convert +------------------ + +Convert users to bots, or a bot to a user + +Synopsis +~~~~~~~~ + + +Convert user accounts to bots or convert bots to user accounts. + +:: + + mmctl user convert (--bot [emails] [usernames] [userIds] | --user --password PASSWORD [--email EMAIL]) [flags] + +Examples +~~~~~~~~ + +:: + + # you can convert a user to a bot providing its email, id or username + $ mmctl user convert user@example.com --bot + + # or multiple users in one go + $ mmctl user convert user@example.com anotherUser --bot + + # you can convert a bot to a user specifying the email and password that the user will have after conversion + $ mmctl user convert botusername --email new.email@email.com --password password --user + +Options +~~~~~~~ + +:: + + --bot If supplied, convert users to bots + --email string The email address for the converted user account. Required when the "bot" flag is set + --firstname string The first name for the converted user account. Required when the "bot" flag is set + -h, --help help for convert + --lastname string The last name for the converted user account. Required when the "bot" flag is set + --locale string The locale (ex: en, fr) for converted new user account. Required when the "bot" flag is set + --nickname string The nickname for the converted user account. Required when the "bot" flag is set + --password string The password for converted new user account. Required when "user" flag is set + --system-admin If supplied, the converted user will be a system administrator. Defaults to false. Required when the "bot" flag is set + --user If supplied, convert a bot to a user + --username string Username for the converted user account. Required when the "bot" flag is set + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_create.rst b/server/cmd/mmctl/docs/mmctl_user_create.rst new file mode 100644 index 0000000000..983dd68ff5 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_create.rst @@ -0,0 +1,72 @@ +.. _mmctl_user_create: + +mmctl user create +----------------- + +Create a user + +Synopsis +~~~~~~~~ + + +Create a user + +:: + + mmctl user create [flags] + +Examples +~~~~~~~~ + +:: + + # You can create a user + $ mmctl user create --email user@example.com --username userexample --password Password1 + + # You can define optional fields like first name, last name and nick name too + $ mmctl user create --email user@example.com --username userexample --password Password1 --firstname User --lastname Example --nickname userex + + # Also you can create the user as system administrator + $ mmctl user create --email user@example.com --username userexample --password Password1 --system-admin + + # Finally you can verify user on creation if you have enough permissions + $ mmctl user create --email user@example.com --username userexample --password Password1 --system-admin --email-verified + +Options +~~~~~~~ + +:: + + --disable-welcome-email Optional. If supplied, the new user will not receive a welcome email. Defaults to false + --email string Required. The email address for the new user account + --email-verified Optional. If supplied, the new user will have the email verified. Defaults to false + --firstname string Optional. The first name for the new user account + --guest Optional. If supplied, the new user will be a guest. Defaults to false + -h, --help help for create + --lastname string Optional. The last name for the new user account + --locale string Optional. The locale (ex: en, fr) for the new user account + --nickname string Optional. The nickname for the new user account + --password string Required. The password for the new user account + --system-admin Optional. If supplied, the new user will be a system administrator. Defaults to false + --username string Required. Username for the new user account + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_deactivate.rst b/server/cmd/mmctl/docs/mmctl_user_deactivate.rst new file mode 100644 index 0000000000..5a2b9ddbf0 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_deactivate.rst @@ -0,0 +1,52 @@ +.. _mmctl_user_deactivate: + +mmctl user deactivate +--------------------- + +Deactivate users + +Synopsis +~~~~~~~~ + + +Deactivate users. Deactivated users are immediately logged out of all sessions and are unable to log back in. + +:: + + mmctl user deactivate [emails, usernames, userIds] [flags] + +Examples +~~~~~~~~ + +:: + + user deactivate user@example.com + user deactivate username + +Options +~~~~~~~ + +:: + + -h, --help help for deactivate + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_delete.rst b/server/cmd/mmctl/docs/mmctl_user_delete.rst new file mode 100644 index 0000000000..1f97fdd431 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_delete.rst @@ -0,0 +1,53 @@ +.. _mmctl_user_delete: + +mmctl user delete +----------------- + +Delete users + +Synopsis +~~~~~~~~ + + +Permanently delete some users. +Permanently deletes one or multiple users along with all related information including posts from the database. + +:: + + mmctl user delete [users] [flags] + +Examples +~~~~~~~~ + +:: + + user delete user@example.com + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to delete the user and a DB backup has been performed + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_deleteall.rst b/server/cmd/mmctl/docs/mmctl_user_deleteall.rst new file mode 100644 index 0000000000..98d9cc7978 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_deleteall.rst @@ -0,0 +1,52 @@ +.. _mmctl_user_deleteall: + +mmctl user deleteall +-------------------- + +Delete all users and all posts. Local command only. + +Synopsis +~~~~~~~~ + + +Permanently delete all users and all related information including posts. This command can only be run in local mode. + +:: + + mmctl user deleteall [flags] + +Examples +~~~~~~~~ + +:: + + user deleteall + +Options +~~~~~~~ + +:: + + --confirm Confirm you really want to delete the user and a DB backup has been performed + -h, --help help for deleteall + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_demote.rst b/server/cmd/mmctl/docs/mmctl_user_demote.rst new file mode 100644 index 0000000000..a3ada280a0 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_demote.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_demote: + +mmctl user demote +----------------- + +Demote users to guests + +Synopsis +~~~~~~~~ + + +Convert a regular user into a guest. + +:: + + mmctl user demote [users] [flags] + +Examples +~~~~~~~~ + +:: + + user demote user1 user2 + +Options +~~~~~~~ + +:: + + -h, --help help for demote + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_email.rst b/server/cmd/mmctl/docs/mmctl_user_email.rst new file mode 100644 index 0000000000..702eb2230e --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_email.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_email: + +mmctl user email +---------------- + +Change email of the user + +Synopsis +~~~~~~~~ + + +Change the email address associated with a user. + +:: + + mmctl user email [user] [new email] [flags] + +Examples +~~~~~~~~ + +:: + + user email testuser user@example.com + +Options +~~~~~~~ + +:: + + -h, --help help for email + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_invite.rst b/server/cmd/mmctl/docs/mmctl_user_invite.rst new file mode 100644 index 0000000000..d6d8f74223 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_invite.rst @@ -0,0 +1,54 @@ +.. _mmctl_user_invite: + +mmctl user invite +----------------- + +Send user an email invite to a team. + +Synopsis +~~~~~~~~ + + +Send user an email invite to a team. +You can invite a user to multiple teams by listing them. +You can specify teams by name or ID. + +:: + + mmctl user invite [email] [teams] [flags] + +Examples +~~~~~~~~ + +:: + + user invite user@example.com myteam + user invite user@example.com myteam1 myteam2 + +Options +~~~~~~~ + +:: + + -h, --help help for invite + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_list.rst b/server/cmd/mmctl/docs/mmctl_user_list.rst new file mode 100644 index 0000000000..2e8c26d5d2 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_list.rst @@ -0,0 +1,55 @@ +.. _mmctl_user_list: + +mmctl user list +--------------- + +List users + +Synopsis +~~~~~~~~ + + +List all users + +:: + + mmctl user list [flags] + +Examples +~~~~~~~~ + +:: + + user list + +Options +~~~~~~~ + +:: + + --all Fetch all users. --page flag will be ignore if provided + -h, --help help for list + --page int Page number to fetch for the list of users + --per-page int Number of users to be fetched (default 200) + --team string If supplied, only users belonging to this team will be listed + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_migrate-auth.rst b/server/cmd/mmctl/docs/mmctl_user_migrate-auth.rst new file mode 100644 index 0000000000..62bf526865 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_migrate-auth.rst @@ -0,0 +1,54 @@ +.. _mmctl_user_migrate-auth: + +mmctl user migrate-auth +----------------------- + +Mass migrate user accounts authentication type + +Synopsis +~~~~~~~~ + + +Migrates accounts from one authentication provider to another. For example, you can upgrade your authentication provider from email to ldap. + +:: + + mmctl user migrate-auth [from_auth] [to_auth] [migration-options] [flags] + +Examples +~~~~~~~~ + +:: + + user migrate-auth email saml users.json + +Options +~~~~~~~ + +:: + + --auto Automatically migrate all users. Assumes the usernames and emails are identical between Mattermost and SAML services. (saml only) + --confirm Confirm you really want to proceed with auto migration. (saml only) + --force Force the migration to occur even if there are duplicates on the LDAP server. Duplicates will not be migrated. (ldap only) + -h, --help help for migrate-auth + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_promote.rst b/server/cmd/mmctl/docs/mmctl_user_promote.rst new file mode 100644 index 0000000000..116cbfac42 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_promote.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_promote: + +mmctl user promote +------------------ + +Promote guests to users + +Synopsis +~~~~~~~~ + + +Convert a guest into a regular user. + +:: + + mmctl user promote [guests] [flags] + +Examples +~~~~~~~~ + +:: + + user promote guest1 guest2 + +Options +~~~~~~~ + +:: + + -h, --help help for promote + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_reset-password.rst b/server/cmd/mmctl/docs/mmctl_user_reset-password.rst new file mode 100644 index 0000000000..92e3f593d3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_reset-password.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_reset-password: + +mmctl user reset-password +------------------------- + +Send users an email to reset their password + +Synopsis +~~~~~~~~ + + +Send users an email to reset their password + +:: + + mmctl user reset-password [users] [flags] + +Examples +~~~~~~~~ + +:: + + user reset-password user@example.com + +Options +~~~~~~~ + +:: + + -h, --help help for reset-password + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_resetmfa.rst b/server/cmd/mmctl/docs/mmctl_user_resetmfa.rst new file mode 100644 index 0000000000..2bb988721b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_resetmfa.rst @@ -0,0 +1,52 @@ +.. _mmctl_user_resetmfa: + +mmctl user resetmfa +------------------- + +Turn off MFA + +Synopsis +~~~~~~~~ + + +Turn off multi-factor authentication for a user. +If MFA enforcement is enabled, the user will be forced to re-enable MFA as soon as they log in. + +:: + + mmctl user resetmfa [users] [flags] + +Examples +~~~~~~~~ + +:: + + user resetmfa user@example.com + +Options +~~~~~~~ + +:: + + -h, --help help for resetmfa + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_search.rst b/server/cmd/mmctl/docs/mmctl_user_search.rst new file mode 100644 index 0000000000..5c34d2fae3 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_search.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_search: + +mmctl user search +----------------- + +Search for users + +Synopsis +~~~~~~~~ + + +Search for users based on username, email, or user ID. + +:: + + mmctl user search [users] [flags] + +Examples +~~~~~~~~ + +:: + + user search user1@mail.com user2@mail.com + +Options +~~~~~~~ + +:: + + -h, --help help for search + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_username.rst b/server/cmd/mmctl/docs/mmctl_user_username.rst new file mode 100644 index 0000000000..d50f6522ee --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_username.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_username: + +mmctl user username +------------------- + +Change username of the user + +Synopsis +~~~~~~~~ + + +Change username of the user. + +:: + + mmctl user username [user] [new username] [flags] + +Examples +~~~~~~~~ + +:: + + user username testuser newusername + +Options +~~~~~~~ + +:: + + -h, --help help for username + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_user_verify.rst b/server/cmd/mmctl/docs/mmctl_user_verify.rst new file mode 100644 index 0000000000..74126502bc --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_user_verify.rst @@ -0,0 +1,51 @@ +.. _mmctl_user_verify: + +mmctl user verify +----------------- + +Mark user's email as verified + +Synopsis +~~~~~~~~ + + +Mark user's email as verified without requiring user to complete email verification path. + +:: + + mmctl user verify [users] [flags] + +Examples +~~~~~~~~ + +:: + + user verify user1 + +Options +~~~~~~~ + +:: + + -h, --help help for verify + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl user `_ - Management of users + diff --git a/server/cmd/mmctl/docs/mmctl_version.rst b/server/cmd/mmctl/docs/mmctl_version.rst new file mode 100644 index 0000000000..8706c9c3c7 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_version.rst @@ -0,0 +1,44 @@ +.. _mmctl_version: + +mmctl version +------------- + +Prints the version of mmctl. + +Synopsis +~~~~~~~~ + + +Prints the version of mmctl. + +:: + + mmctl version [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for version + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/docs/mmctl_webhook.rst b/server/cmd/mmctl/docs/mmctl_webhook.rst new file mode 100644 index 0000000000..3b04996b6b --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook.rst @@ -0,0 +1,47 @@ +.. _mmctl_webhook: + +mmctl webhook +------------- + +Management of webhooks + +Synopsis +~~~~~~~~ + + +Management of webhooks + +Options +~~~~~~~ + +:: + + -h, --help help for webhook + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative +* `mmctl webhook create-incoming `_ - Create incoming webhook +* `mmctl webhook create-outgoing `_ - Create outgoing webhook +* `mmctl webhook delete `_ - Delete webhooks +* `mmctl webhook list `_ - List webhooks +* `mmctl webhook modify-incoming `_ - Modify incoming webhook +* `mmctl webhook modify-outgoing `_ - Modify outgoing webhook +* `mmctl webhook show `_ - Show a webhook + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_create-incoming.rst b/server/cmd/mmctl/docs/mmctl_webhook_create-incoming.rst new file mode 100644 index 0000000000..891aed7e22 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_create-incoming.rst @@ -0,0 +1,57 @@ +.. _mmctl_webhook_create-incoming: + +mmctl webhook create-incoming +----------------------------- + +Create incoming webhook + +Synopsis +~~~~~~~~ + + +create incoming webhook which allows external posting of messages to specific channel + +:: + + mmctl webhook create-incoming [flags] + +Examples +~~~~~~~~ + +:: + + webhook create-incoming --channel [channelID] --user [userID] --display-name [displayName] --description [webhookDescription] --lock-to-channel --icon [iconURL] + +Options +~~~~~~~ + +:: + + --channel string Channel ID (required) + --description string Incoming webhook description + --display-name string Incoming webhook display name + -h, --help help for create-incoming + --icon string Icon URL + --lock-to-channel Lock to channel + --user string User ID (required) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_create-outgoing.rst b/server/cmd/mmctl/docs/mmctl_webhook_create-outgoing.rst new file mode 100644 index 0000000000..9d312bf3ee --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_create-outgoing.rst @@ -0,0 +1,62 @@ +.. _mmctl_webhook_create-outgoing: + +mmctl webhook create-outgoing +----------------------------- + +Create outgoing webhook + +Synopsis +~~~~~~~~ + + +create outgoing webhook which allows external posting of messages from a specific channel + +:: + + mmctl webhook create-outgoing [flags] + +Examples +~~~~~~~~ + +:: + + webhook create-outgoing --team myteam --user myusername --display-name mywebhook --trigger-word "build" --trigger-word "test" --url http://localhost:8000/my-webhook-handler + webhook create-outgoing --team myteam --channel mychannel --user myusername --display-name mywebhook --description "My cool webhook" --trigger-when start --trigger-word build --trigger-word test --icon http://localhost:8000/my-slash-handler-bot-icon.png --url http://localhost:8000/my-webhook-handler --content-type "application/json" + +Options +~~~~~~~ + +:: + + --channel string Channel name or ID + --content-type string Content-type + --description string Outgoing webhook description + --display-name string Outgoing webhook display name (required) + -h, --help help for create-outgoing + --icon string Icon URL + --team string Team name or ID (required) + --trigger-when string When to trigger webhook (exact: for first word matches a trigger word exactly, start: for first word starts with a trigger word) (default "exact") + --trigger-word stringArray Word to trigger webhook (required) + --url stringArray Callback URL (required) + --user string User username, email, or ID (required) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_delete.rst b/server/cmd/mmctl/docs/mmctl_webhook_delete.rst new file mode 100644 index 0000000000..0a23c4339d --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_delete.rst @@ -0,0 +1,51 @@ +.. _mmctl_webhook_delete: + +mmctl webhook delete +-------------------- + +Delete webhooks + +Synopsis +~~~~~~~~ + + +Delete webhook with given id + +:: + + mmctl webhook delete [flags] + +Examples +~~~~~~~~ + +:: + + webhook delete [webhookID] + +Options +~~~~~~~ + +:: + + -h, --help help for delete + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_list.rst b/server/cmd/mmctl/docs/mmctl_webhook_list.rst new file mode 100644 index 0000000000..5639291742 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_list.rst @@ -0,0 +1,51 @@ +.. _mmctl_webhook_list: + +mmctl webhook list +------------------ + +List webhooks + +Synopsis +~~~~~~~~ + + +list all webhooks + +:: + + mmctl webhook list [flags] + +Examples +~~~~~~~~ + +:: + + webhook list myteam + +Options +~~~~~~~ + +:: + + -h, --help help for list + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_modify-incoming.rst b/server/cmd/mmctl/docs/mmctl_webhook_modify-incoming.rst new file mode 100644 index 0000000000..641285369d --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_modify-incoming.rst @@ -0,0 +1,56 @@ +.. _mmctl_webhook_modify-incoming: + +mmctl webhook modify-incoming +----------------------------- + +Modify incoming webhook + +Synopsis +~~~~~~~~ + + +Modify existing incoming webhook by changing its title, description, channel or icon url + +:: + + mmctl webhook modify-incoming [flags] + +Examples +~~~~~~~~ + +:: + + webhook modify-incoming [webhookID] --channel [channelID] --display-name [displayName] --description [webhookDescription] --lock-to-channel --icon [iconURL] + +Options +~~~~~~~ + +:: + + --channel string Channel ID + --description string Incoming webhook description + --display-name string Incoming webhook display name + -h, --help help for modify-incoming + --icon string Icon URL + --lock-to-channel Lock to channel + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_modify-outgoing.rst b/server/cmd/mmctl/docs/mmctl_webhook_modify-outgoing.rst new file mode 100644 index 0000000000..37d1e97736 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_modify-outgoing.rst @@ -0,0 +1,59 @@ +.. _mmctl_webhook_modify-outgoing: + +mmctl webhook modify-outgoing +----------------------------- + +Modify outgoing webhook + +Synopsis +~~~~~~~~ + + +Modify existing outgoing webhook by changing its title, description, channel, icon, url, content-type, and triggers + +:: + + mmctl webhook modify-outgoing [flags] + +Examples +~~~~~~~~ + +:: + + webhook modify-outgoing [webhookId] --channel [channelId] --display-name [displayName] --description "New webhook description" --icon http://localhost:8000/my-slash-handler-bot-icon.png --url http://localhost:8000/my-webhook-handler --content-type "application/json" --trigger-word test --trigger-when start + +Options +~~~~~~~ + +:: + + --channel string Channel name or ID + --content-type string Content-type + --description string Outgoing webhook description + --display-name string Outgoing webhook display name + -h, --help help for modify-outgoing + --icon string Icon URL + --trigger-when string When to trigger webhook (exact: for first word matches a trigger word exactly, start: for first word starts with a trigger word) + --trigger-word stringArray Word to trigger webhook + --url stringArray Callback URL + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_webhook_show.rst b/server/cmd/mmctl/docs/mmctl_webhook_show.rst new file mode 100644 index 0000000000..a261c76b41 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_webhook_show.rst @@ -0,0 +1,51 @@ +.. _mmctl_webhook_show: + +mmctl webhook show +------------------ + +Show a webhook + +Synopsis +~~~~~~~~ + + +Show the webhook specified by [webhookId] + +:: + + mmctl webhook show [webhookId] [flags] + +Examples +~~~~~~~~ + +:: + + webhook show w16zb5tu3n1zkqo18goqry1je + +Options +~~~~~~~ + +:: + + -h, --help help for show + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl webhook `_ - Management of webhooks + diff --git a/server/cmd/mmctl/docs/mmctl_websocket.rst b/server/cmd/mmctl/docs/mmctl_websocket.rst new file mode 100644 index 0000000000..c8444b64e2 --- /dev/null +++ b/server/cmd/mmctl/docs/mmctl_websocket.rst @@ -0,0 +1,44 @@ +.. _mmctl_websocket: + +mmctl websocket +--------------- + +Display websocket in a human-readable format + +Synopsis +~~~~~~~~ + + +Display websocket in a human-readable format + +:: + + mmctl websocket [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for websocket + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config") + --disable-pager disables paged output + --insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1 + --insecure-tls-version allows to use TLS versions 1.0 and 1.1 + --json the output format will be in json format + --local allows communicating with the server through a unix socket + --quiet prevent mmctl to generate output for the commands + --strict will only run commands if the mmctl version matches the server one + --suppress-warnings disables printing warning messages + +SEE ALSO +~~~~~~~~ + +* `mmctl `_ - Remote client for the Open Source, self-hosted Slack-alternative + diff --git a/server/cmd/mmctl/mmctl.go b/server/cmd/mmctl/mmctl.go new file mode 100644 index 0000000000..36cfbf9f5d --- /dev/null +++ b/server/cmd/mmctl/mmctl.go @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "os" + + _ "github.com/golang/mock/mockgen/model" + + "github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/commands" +) + +func main() { + if err := commands.Run(os.Args[1:]); err != nil { + os.Exit(1) + } +} diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go new file mode 100644 index 0000000000..1ea3f94098 --- /dev/null +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -0,0 +1,2164 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost/mattermost-server/server/v8/cmd/mmctl/client (interfaces: Client) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + io "io" + http "net/http" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + model "github.com/mattermost/mattermost-server/server/public/model" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// AddChannelMember mocks base method. +func (m *MockClient) AddChannelMember(arg0, arg1 string) (*model.ChannelMember, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddChannelMember", arg0, arg1) + ret0, _ := ret[0].(*model.ChannelMember) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// AddChannelMember indicates an expected call of AddChannelMember. +func (mr *MockClientMockRecorder) AddChannelMember(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddChannelMember", reflect.TypeOf((*MockClient)(nil).AddChannelMember), arg0, arg1) +} + +// AddTeamMember mocks base method. +func (m *MockClient) AddTeamMember(arg0, arg1 string) (*model.TeamMember, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddTeamMember", arg0, arg1) + ret0, _ := ret[0].(*model.TeamMember) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// AddTeamMember indicates an expected call of AddTeamMember. +func (mr *MockClientMockRecorder) AddTeamMember(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTeamMember", reflect.TypeOf((*MockClient)(nil).AddTeamMember), arg0, arg1) +} + +// AssignBot mocks base method. +func (m *MockClient) AssignBot(arg0, arg1 string) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AssignBot", arg0, arg1) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// AssignBot indicates an expected call of AssignBot. +func (mr *MockClientMockRecorder) AssignBot(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssignBot", reflect.TypeOf((*MockClient)(nil).AssignBot), arg0, arg1) +} + +// CancelJob mocks base method. +func (m *MockClient) CancelJob(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CancelJob", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CancelJob indicates an expected call of CancelJob. +func (mr *MockClientMockRecorder) CancelJob(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelJob", reflect.TypeOf((*MockClient)(nil).CancelJob), arg0) +} + +// CheckIntegrity mocks base method. +func (m *MockClient) CheckIntegrity() ([]model.IntegrityCheckResult, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckIntegrity") + ret0, _ := ret[0].([]model.IntegrityCheckResult) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CheckIntegrity indicates an expected call of CheckIntegrity. +func (mr *MockClientMockRecorder) CheckIntegrity() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckIntegrity", reflect.TypeOf((*MockClient)(nil).CheckIntegrity)) +} + +// ClearServerBusy mocks base method. +func (m *MockClient) ClearServerBusy() (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearServerBusy") + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClearServerBusy indicates an expected call of ClearServerBusy. +func (mr *MockClientMockRecorder) ClearServerBusy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearServerBusy", reflect.TypeOf((*MockClient)(nil).ClearServerBusy)) +} + +// ConvertBotToUser mocks base method. +func (m *MockClient) ConvertBotToUser(arg0 string, arg1 *model.UserPatch, arg2 bool) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConvertBotToUser", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ConvertBotToUser indicates an expected call of ConvertBotToUser. +func (mr *MockClientMockRecorder) ConvertBotToUser(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConvertBotToUser", reflect.TypeOf((*MockClient)(nil).ConvertBotToUser), arg0, arg1, arg2) +} + +// ConvertUserToBot mocks base method. +func (m *MockClient) ConvertUserToBot(arg0 string) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConvertUserToBot", arg0) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ConvertUserToBot indicates an expected call of ConvertUserToBot. +func (mr *MockClientMockRecorder) ConvertUserToBot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConvertUserToBot", reflect.TypeOf((*MockClient)(nil).ConvertUserToBot), arg0) +} + +// CreateBot mocks base method. +func (m *MockClient) CreateBot(arg0 *model.Bot) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateBot", arg0) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateBot indicates an expected call of CreateBot. +func (mr *MockClientMockRecorder) CreateBot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBot", reflect.TypeOf((*MockClient)(nil).CreateBot), arg0) +} + +// CreateChannel mocks base method. +func (m *MockClient) CreateChannel(arg0 *model.Channel) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateChannel", arg0) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateChannel indicates an expected call of CreateChannel. +func (mr *MockClientMockRecorder) CreateChannel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChannel", reflect.TypeOf((*MockClient)(nil).CreateChannel), arg0) +} + +// CreateCommand mocks base method. +func (m *MockClient) CreateCommand(arg0 *model.Command) (*model.Command, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCommand", arg0) + ret0, _ := ret[0].(*model.Command) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateCommand indicates an expected call of CreateCommand. +func (mr *MockClientMockRecorder) CreateCommand(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCommand", reflect.TypeOf((*MockClient)(nil).CreateCommand), arg0) +} + +// CreateIncomingWebhook mocks base method. +func (m *MockClient) CreateIncomingWebhook(arg0 *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateIncomingWebhook", arg0) + ret0, _ := ret[0].(*model.IncomingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateIncomingWebhook indicates an expected call of CreateIncomingWebhook. +func (mr *MockClientMockRecorder) CreateIncomingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIncomingWebhook", reflect.TypeOf((*MockClient)(nil).CreateIncomingWebhook), arg0) +} + +// CreateJob mocks base method. +func (m *MockClient) CreateJob(arg0 *model.Job) (*model.Job, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateJob", arg0) + ret0, _ := ret[0].(*model.Job) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateJob indicates an expected call of CreateJob. +func (mr *MockClientMockRecorder) CreateJob(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJob", reflect.TypeOf((*MockClient)(nil).CreateJob), arg0) +} + +// CreateOutgoingWebhook mocks base method. +func (m *MockClient) CreateOutgoingWebhook(arg0 *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOutgoingWebhook", arg0) + ret0, _ := ret[0].(*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateOutgoingWebhook indicates an expected call of CreateOutgoingWebhook. +func (mr *MockClientMockRecorder) CreateOutgoingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOutgoingWebhook", reflect.TypeOf((*MockClient)(nil).CreateOutgoingWebhook), arg0) +} + +// CreatePost mocks base method. +func (m *MockClient) CreatePost(arg0 *model.Post) (*model.Post, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePost", arg0) + ret0, _ := ret[0].(*model.Post) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreatePost indicates an expected call of CreatePost. +func (mr *MockClientMockRecorder) CreatePost(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockClient)(nil).CreatePost), arg0) +} + +// CreateTeam mocks base method. +func (m *MockClient) CreateTeam(arg0 *model.Team) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTeam", arg0) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateTeam indicates an expected call of CreateTeam. +func (mr *MockClientMockRecorder) CreateTeam(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeam", reflect.TypeOf((*MockClient)(nil).CreateTeam), arg0) +} + +// CreateUpload mocks base method. +func (m *MockClient) CreateUpload(arg0 *model.UploadSession) (*model.UploadSession, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUpload", arg0) + ret0, _ := ret[0].(*model.UploadSession) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateUpload indicates an expected call of CreateUpload. +func (mr *MockClientMockRecorder) CreateUpload(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUpload", reflect.TypeOf((*MockClient)(nil).CreateUpload), arg0) +} + +// CreateUser mocks base method. +func (m *MockClient) CreateUser(arg0 *model.User) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUser", arg0) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateUser indicates an expected call of CreateUser. +func (mr *MockClientMockRecorder) CreateUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockClient)(nil).CreateUser), arg0) +} + +// CreateUserAccessToken mocks base method. +func (m *MockClient) CreateUserAccessToken(arg0, arg1 string) (*model.UserAccessToken, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserAccessToken", arg0, arg1) + ret0, _ := ret[0].(*model.UserAccessToken) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateUserAccessToken indicates an expected call of CreateUserAccessToken. +func (mr *MockClientMockRecorder) CreateUserAccessToken(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserAccessToken", reflect.TypeOf((*MockClient)(nil).CreateUserAccessToken), arg0, arg1) +} + +// DeleteChannel mocks base method. +func (m *MockClient) DeleteChannel(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChannel", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteChannel indicates an expected call of DeleteChannel. +func (mr *MockClientMockRecorder) DeleteChannel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChannel", reflect.TypeOf((*MockClient)(nil).DeleteChannel), arg0) +} + +// DeleteCommand mocks base method. +func (m *MockClient) DeleteCommand(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCommand", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteCommand indicates an expected call of DeleteCommand. +func (mr *MockClientMockRecorder) DeleteCommand(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCommand", reflect.TypeOf((*MockClient)(nil).DeleteCommand), arg0) +} + +// DeleteExport mocks base method. +func (m *MockClient) DeleteExport(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteExport", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteExport indicates an expected call of DeleteExport. +func (mr *MockClientMockRecorder) DeleteExport(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExport", reflect.TypeOf((*MockClient)(nil).DeleteExport), arg0) +} + +// DeleteIncomingWebhook mocks base method. +func (m *MockClient) DeleteIncomingWebhook(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteIncomingWebhook", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteIncomingWebhook indicates an expected call of DeleteIncomingWebhook. +func (mr *MockClientMockRecorder) DeleteIncomingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteIncomingWebhook", reflect.TypeOf((*MockClient)(nil).DeleteIncomingWebhook), arg0) +} + +// DeleteOutgoingWebhook mocks base method. +func (m *MockClient) DeleteOutgoingWebhook(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOutgoingWebhook", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOutgoingWebhook indicates an expected call of DeleteOutgoingWebhook. +func (mr *MockClientMockRecorder) DeleteOutgoingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOutgoingWebhook", reflect.TypeOf((*MockClient)(nil).DeleteOutgoingWebhook), arg0) +} + +// DemoteUserToGuest mocks base method. +func (m *MockClient) DemoteUserToGuest(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DemoteUserToGuest", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DemoteUserToGuest indicates an expected call of DemoteUserToGuest. +func (mr *MockClientMockRecorder) DemoteUserToGuest(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DemoteUserToGuest", reflect.TypeOf((*MockClient)(nil).DemoteUserToGuest), arg0) +} + +// DisableBot mocks base method. +func (m *MockClient) DisableBot(arg0 string) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisableBot", arg0) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// DisableBot indicates an expected call of DisableBot. +func (mr *MockClientMockRecorder) DisableBot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisableBot", reflect.TypeOf((*MockClient)(nil).DisableBot), arg0) +} + +// DisablePlugin mocks base method. +func (m *MockClient) DisablePlugin(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisablePlugin", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DisablePlugin indicates an expected call of DisablePlugin. +func (mr *MockClientMockRecorder) DisablePlugin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisablePlugin", reflect.TypeOf((*MockClient)(nil).DisablePlugin), arg0) +} + +// DoAPIPost mocks base method. +func (m *MockClient) DoAPIPost(arg0, arg1 string) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DoAPIPost", arg0, arg1) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DoAPIPost indicates an expected call of DoAPIPost. +func (mr *MockClientMockRecorder) DoAPIPost(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoAPIPost", reflect.TypeOf((*MockClient)(nil).DoAPIPost), arg0, arg1) +} + +// DownloadExport mocks base method. +func (m *MockClient) DownloadExport(arg0 string, arg1 io.Writer, arg2 int64) (int64, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DownloadExport", arg0, arg1, arg2) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// DownloadExport indicates an expected call of DownloadExport. +func (mr *MockClientMockRecorder) DownloadExport(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadExport", reflect.TypeOf((*MockClient)(nil).DownloadExport), arg0, arg1, arg2) +} + +// EnableBot mocks base method. +func (m *MockClient) EnableBot(arg0 string) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnableBot", arg0) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// EnableBot indicates an expected call of EnableBot. +func (mr *MockClientMockRecorder) EnableBot(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableBot", reflect.TypeOf((*MockClient)(nil).EnableBot), arg0) +} + +// EnablePlugin mocks base method. +func (m *MockClient) EnablePlugin(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnablePlugin", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EnablePlugin indicates an expected call of EnablePlugin. +func (mr *MockClientMockRecorder) EnablePlugin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnablePlugin", reflect.TypeOf((*MockClient)(nil).EnablePlugin), arg0) +} + +// GetAllTeams mocks base method. +func (m *MockClient) GetAllTeams(arg0 string, arg1, arg2 int) ([]*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllTeams", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAllTeams indicates an expected call of GetAllTeams. +func (mr *MockClientMockRecorder) GetAllTeams(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllTeams", reflect.TypeOf((*MockClient)(nil).GetAllTeams), arg0, arg1, arg2) +} + +// GetBots mocks base method. +func (m *MockClient) GetBots(arg0, arg1 int, arg2 string) ([]*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBots", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBots indicates an expected call of GetBots. +func (mr *MockClientMockRecorder) GetBots(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBots", reflect.TypeOf((*MockClient)(nil).GetBots), arg0, arg1, arg2) +} + +// GetBotsIncludeDeleted mocks base method. +func (m *MockClient) GetBotsIncludeDeleted(arg0, arg1 int, arg2 string) ([]*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBotsIncludeDeleted", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBotsIncludeDeleted indicates an expected call of GetBotsIncludeDeleted. +func (mr *MockClientMockRecorder) GetBotsIncludeDeleted(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBotsIncludeDeleted", reflect.TypeOf((*MockClient)(nil).GetBotsIncludeDeleted), arg0, arg1, arg2) +} + +// GetBotsOrphaned mocks base method. +func (m *MockClient) GetBotsOrphaned(arg0, arg1 int, arg2 string) ([]*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBotsOrphaned", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetBotsOrphaned indicates an expected call of GetBotsOrphaned. +func (mr *MockClientMockRecorder) GetBotsOrphaned(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBotsOrphaned", reflect.TypeOf((*MockClient)(nil).GetBotsOrphaned), arg0, arg1, arg2) +} + +// GetChannel mocks base method. +func (m *MockClient) GetChannel(arg0, arg1 string) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChannel", arg0, arg1) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetChannel indicates an expected call of GetChannel. +func (mr *MockClientMockRecorder) GetChannel(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockClient)(nil).GetChannel), arg0, arg1) +} + +// GetChannelByName mocks base method. +func (m *MockClient) GetChannelByName(arg0, arg1, arg2 string) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChannelByName", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetChannelByName indicates an expected call of GetChannelByName. +func (mr *MockClientMockRecorder) GetChannelByName(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByName", reflect.TypeOf((*MockClient)(nil).GetChannelByName), arg0, arg1, arg2) +} + +// GetChannelByNameIncludeDeleted mocks base method. +func (m *MockClient) GetChannelByNameIncludeDeleted(arg0, arg1, arg2 string) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChannelByNameIncludeDeleted", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetChannelByNameIncludeDeleted indicates an expected call of GetChannelByNameIncludeDeleted. +func (mr *MockClientMockRecorder) GetChannelByNameIncludeDeleted(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByNameIncludeDeleted", reflect.TypeOf((*MockClient)(nil).GetChannelByNameIncludeDeleted), arg0, arg1, arg2) +} + +// GetChannelMembers mocks base method. +func (m *MockClient) GetChannelMembers(arg0 string, arg1, arg2 int, arg3 string) (model.ChannelMembers, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChannelMembers", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(model.ChannelMembers) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetChannelMembers indicates an expected call of GetChannelMembers. +func (mr *MockClientMockRecorder) GetChannelMembers(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelMembers", reflect.TypeOf((*MockClient)(nil).GetChannelMembers), arg0, arg1, arg2, arg3) +} + +// GetChannelsForTeamForUser mocks base method. +func (m *MockClient) GetChannelsForTeamForUser(arg0, arg1 string, arg2 bool, arg3 string) ([]*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChannelsForTeamForUser", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetChannelsForTeamForUser indicates an expected call of GetChannelsForTeamForUser. +func (mr *MockClientMockRecorder) GetChannelsForTeamForUser(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelsForTeamForUser", reflect.TypeOf((*MockClient)(nil).GetChannelsForTeamForUser), arg0, arg1, arg2, arg3) +} + +// GetCommandById mocks base method. +func (m *MockClient) GetCommandById(arg0 string) (*model.Command, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCommandById", arg0) + ret0, _ := ret[0].(*model.Command) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetCommandById indicates an expected call of GetCommandById. +func (mr *MockClientMockRecorder) GetCommandById(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCommandById", reflect.TypeOf((*MockClient)(nil).GetCommandById), arg0) +} + +// GetConfig mocks base method. +func (m *MockClient) GetConfig() (*model.Config, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetConfig") + ret0, _ := ret[0].(*model.Config) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetConfig indicates an expected call of GetConfig. +func (mr *MockClientMockRecorder) GetConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockClient)(nil).GetConfig)) +} + +// GetDeletedChannelsForTeam mocks base method. +func (m *MockClient) GetDeletedChannelsForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDeletedChannelsForTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetDeletedChannelsForTeam indicates an expected call of GetDeletedChannelsForTeam. +func (mr *MockClientMockRecorder) GetDeletedChannelsForTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeletedChannelsForTeam", reflect.TypeOf((*MockClient)(nil).GetDeletedChannelsForTeam), arg0, arg1, arg2, arg3) +} + +// GetGroupsByChannel mocks base method. +func (m *MockClient) GetGroupsByChannel(arg0 string, arg1 model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByChannel", arg0, arg1) + ret0, _ := ret[0].([]*model.GroupWithSchemeAdmin) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(*model.Response) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// GetGroupsByChannel indicates an expected call of GetGroupsByChannel. +func (mr *MockClientMockRecorder) GetGroupsByChannel(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByChannel", reflect.TypeOf((*MockClient)(nil).GetGroupsByChannel), arg0, arg1) +} + +// GetGroupsByTeam mocks base method. +func (m *MockClient) GetGroupsByTeam(arg0 string, arg1 model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByTeam", arg0, arg1) + ret0, _ := ret[0].([]*model.GroupWithSchemeAdmin) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(*model.Response) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// GetGroupsByTeam indicates an expected call of GetGroupsByTeam. +func (mr *MockClientMockRecorder) GetGroupsByTeam(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByTeam", reflect.TypeOf((*MockClient)(nil).GetGroupsByTeam), arg0, arg1) +} + +// GetIncomingWebhook mocks base method. +func (m *MockClient) GetIncomingWebhook(arg0, arg1 string) (*model.IncomingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIncomingWebhook", arg0, arg1) + ret0, _ := ret[0].(*model.IncomingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetIncomingWebhook indicates an expected call of GetIncomingWebhook. +func (mr *MockClientMockRecorder) GetIncomingWebhook(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIncomingWebhook", reflect.TypeOf((*MockClient)(nil).GetIncomingWebhook), arg0, arg1) +} + +// GetIncomingWebhooks mocks base method. +func (m *MockClient) GetIncomingWebhooks(arg0, arg1 int, arg2 string) ([]*model.IncomingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIncomingWebhooks", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.IncomingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetIncomingWebhooks indicates an expected call of GetIncomingWebhooks. +func (mr *MockClientMockRecorder) GetIncomingWebhooks(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIncomingWebhooks", reflect.TypeOf((*MockClient)(nil).GetIncomingWebhooks), arg0, arg1, arg2) +} + +// GetIncomingWebhooksForTeam mocks base method. +func (m *MockClient) GetIncomingWebhooksForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.IncomingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIncomingWebhooksForTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.IncomingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetIncomingWebhooksForTeam indicates an expected call of GetIncomingWebhooksForTeam. +func (mr *MockClientMockRecorder) GetIncomingWebhooksForTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIncomingWebhooksForTeam", reflect.TypeOf((*MockClient)(nil).GetIncomingWebhooksForTeam), arg0, arg1, arg2, arg3) +} + +// GetJob mocks base method. +func (m *MockClient) GetJob(arg0 string) (*model.Job, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetJob", arg0) + ret0, _ := ret[0].(*model.Job) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetJob indicates an expected call of GetJob. +func (mr *MockClientMockRecorder) GetJob(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetJob", reflect.TypeOf((*MockClient)(nil).GetJob), arg0) +} + +// GetJobs mocks base method. +func (m *MockClient) GetJobs(arg0, arg1 int) ([]*model.Job, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetJobs", arg0, arg1) + ret0, _ := ret[0].([]*model.Job) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetJobs indicates an expected call of GetJobs. +func (mr *MockClientMockRecorder) GetJobs(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetJobs", reflect.TypeOf((*MockClient)(nil).GetJobs), arg0, arg1) +} + +// GetJobsByType mocks base method. +func (m *MockClient) GetJobsByType(arg0 string, arg1, arg2 int) ([]*model.Job, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetJobsByType", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Job) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetJobsByType indicates an expected call of GetJobsByType. +func (mr *MockClientMockRecorder) GetJobsByType(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetJobsByType", reflect.TypeOf((*MockClient)(nil).GetJobsByType), arg0, arg1, arg2) +} + +// GetLdapGroups mocks base method. +func (m *MockClient) GetLdapGroups() ([]*model.Group, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLdapGroups") + ret0, _ := ret[0].([]*model.Group) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetLdapGroups indicates an expected call of GetLdapGroups. +func (mr *MockClientMockRecorder) GetLdapGroups() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLdapGroups", reflect.TypeOf((*MockClient)(nil).GetLdapGroups)) +} + +// GetLogs mocks base method. +func (m *MockClient) GetLogs(arg0, arg1 int) ([]string, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLogs", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetLogs indicates an expected call of GetLogs. +func (mr *MockClientMockRecorder) GetLogs(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogs", reflect.TypeOf((*MockClient)(nil).GetLogs), arg0, arg1) +} + +// GetMarketplacePlugins mocks base method. +func (m *MockClient) GetMarketplacePlugins(arg0 *model.MarketplacePluginFilter) ([]*model.MarketplacePlugin, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMarketplacePlugins", arg0) + ret0, _ := ret[0].([]*model.MarketplacePlugin) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetMarketplacePlugins indicates an expected call of GetMarketplacePlugins. +func (mr *MockClientMockRecorder) GetMarketplacePlugins(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMarketplacePlugins", reflect.TypeOf((*MockClient)(nil).GetMarketplacePlugins), arg0) +} + +// GetOutgoingWebhook mocks base method. +func (m *MockClient) GetOutgoingWebhook(arg0 string) (*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOutgoingWebhook", arg0) + ret0, _ := ret[0].(*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOutgoingWebhook indicates an expected call of GetOutgoingWebhook. +func (mr *MockClientMockRecorder) GetOutgoingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOutgoingWebhook", reflect.TypeOf((*MockClient)(nil).GetOutgoingWebhook), arg0) +} + +// GetOutgoingWebhooks mocks base method. +func (m *MockClient) GetOutgoingWebhooks(arg0, arg1 int, arg2 string) ([]*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOutgoingWebhooks", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOutgoingWebhooks indicates an expected call of GetOutgoingWebhooks. +func (mr *MockClientMockRecorder) GetOutgoingWebhooks(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOutgoingWebhooks", reflect.TypeOf((*MockClient)(nil).GetOutgoingWebhooks), arg0, arg1, arg2) +} + +// GetOutgoingWebhooksForChannel mocks base method. +func (m *MockClient) GetOutgoingWebhooksForChannel(arg0 string, arg1, arg2 int, arg3 string) ([]*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOutgoingWebhooksForChannel", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOutgoingWebhooksForChannel indicates an expected call of GetOutgoingWebhooksForChannel. +func (mr *MockClientMockRecorder) GetOutgoingWebhooksForChannel(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOutgoingWebhooksForChannel", reflect.TypeOf((*MockClient)(nil).GetOutgoingWebhooksForChannel), arg0, arg1, arg2, arg3) +} + +// GetOutgoingWebhooksForTeam mocks base method. +func (m *MockClient) GetOutgoingWebhooksForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOutgoingWebhooksForTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOutgoingWebhooksForTeam indicates an expected call of GetOutgoingWebhooksForTeam. +func (mr *MockClientMockRecorder) GetOutgoingWebhooksForTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOutgoingWebhooksForTeam", reflect.TypeOf((*MockClient)(nil).GetOutgoingWebhooksForTeam), arg0, arg1, arg2, arg3) +} + +// GetPing mocks base method. +func (m *MockClient) GetPing() (string, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPing") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPing indicates an expected call of GetPing. +func (mr *MockClientMockRecorder) GetPing() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPing", reflect.TypeOf((*MockClient)(nil).GetPing)) +} + +// GetPingWithFullServerStatus mocks base method. +func (m *MockClient) GetPingWithFullServerStatus() (map[string]string, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPingWithFullServerStatus") + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPingWithFullServerStatus indicates an expected call of GetPingWithFullServerStatus. +func (mr *MockClientMockRecorder) GetPingWithFullServerStatus() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPingWithFullServerStatus", reflect.TypeOf((*MockClient)(nil).GetPingWithFullServerStatus)) +} + +// GetPlugins mocks base method. +func (m *MockClient) GetPlugins() (*model.PluginsResponse, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPlugins") + ret0, _ := ret[0].(*model.PluginsResponse) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPlugins indicates an expected call of GetPlugins. +func (mr *MockClientMockRecorder) GetPlugins() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlugins", reflect.TypeOf((*MockClient)(nil).GetPlugins)) +} + +// GetPost mocks base method. +func (m *MockClient) GetPost(arg0, arg1 string) (*model.Post, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPost", arg0, arg1) + ret0, _ := ret[0].(*model.Post) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPost indicates an expected call of GetPost. +func (mr *MockClientMockRecorder) GetPost(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPost", reflect.TypeOf((*MockClient)(nil).GetPost), arg0, arg1) +} + +// GetPostsForChannel mocks base method. +func (m *MockClient) GetPostsForChannel(arg0 string, arg1, arg2 int, arg3 string, arg4, arg5 bool) (*model.PostList, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostsForChannel", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*model.PostList) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPostsForChannel indicates an expected call of GetPostsForChannel. +func (mr *MockClientMockRecorder) GetPostsForChannel(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsForChannel", reflect.TypeOf((*MockClient)(nil).GetPostsForChannel), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// GetPostsSince mocks base method. +func (m *MockClient) GetPostsSince(arg0 string, arg1 int64, arg2 bool) (*model.PostList, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostsSince", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.PostList) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPostsSince indicates an expected call of GetPostsSince. +func (mr *MockClientMockRecorder) GetPostsSince(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostsSince", reflect.TypeOf((*MockClient)(nil).GetPostsSince), arg0, arg1, arg2) +} + +// GetPrivateChannelsForTeam mocks base method. +func (m *MockClient) GetPrivateChannelsForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPrivateChannelsForTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPrivateChannelsForTeam indicates an expected call of GetPrivateChannelsForTeam. +func (mr *MockClientMockRecorder) GetPrivateChannelsForTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateChannelsForTeam", reflect.TypeOf((*MockClient)(nil).GetPrivateChannelsForTeam), arg0, arg1, arg2, arg3) +} + +// GetPublicChannelsForTeam mocks base method. +func (m *MockClient) GetPublicChannelsForTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPublicChannelsForTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPublicChannelsForTeam indicates an expected call of GetPublicChannelsForTeam. +func (mr *MockClientMockRecorder) GetPublicChannelsForTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicChannelsForTeam", reflect.TypeOf((*MockClient)(nil).GetPublicChannelsForTeam), arg0, arg1, arg2, arg3) +} + +// GetRoleByName mocks base method. +func (m *MockClient) GetRoleByName(arg0 string) (*model.Role, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoleByName", arg0) + ret0, _ := ret[0].(*model.Role) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetRoleByName indicates an expected call of GetRoleByName. +func (mr *MockClientMockRecorder) GetRoleByName(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoleByName", reflect.TypeOf((*MockClient)(nil).GetRoleByName), arg0) +} + +// GetServerBusy mocks base method. +func (m *MockClient) GetServerBusy() (*model.ServerBusyState, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServerBusy") + ret0, _ := ret[0].(*model.ServerBusyState) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetServerBusy indicates an expected call of GetServerBusy. +func (mr *MockClientMockRecorder) GetServerBusy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServerBusy", reflect.TypeOf((*MockClient)(nil).GetServerBusy)) +} + +// GetTeam mocks base method. +func (m *MockClient) GetTeam(arg0, arg1 string) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeam", arg0, arg1) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetTeam indicates an expected call of GetTeam. +func (mr *MockClientMockRecorder) GetTeam(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockClient)(nil).GetTeam), arg0, arg1) +} + +// GetTeamByName mocks base method. +func (m *MockClient) GetTeamByName(arg0, arg1 string) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeamByName", arg0, arg1) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetTeamByName indicates an expected call of GetTeamByName. +func (mr *MockClientMockRecorder) GetTeamByName(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamByName", reflect.TypeOf((*MockClient)(nil).GetTeamByName), arg0, arg1) +} + +// GetUpload mocks base method. +func (m *MockClient) GetUpload(arg0 string) (*model.UploadSession, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUpload", arg0) + ret0, _ := ret[0].(*model.UploadSession) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUpload indicates an expected call of GetUpload. +func (mr *MockClientMockRecorder) GetUpload(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUpload", reflect.TypeOf((*MockClient)(nil).GetUpload), arg0) +} + +// GetUploadsForUser mocks base method. +func (m *MockClient) GetUploadsForUser(arg0 string) ([]*model.UploadSession, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUploadsForUser", arg0) + ret0, _ := ret[0].([]*model.UploadSession) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUploadsForUser indicates an expected call of GetUploadsForUser. +func (mr *MockClientMockRecorder) GetUploadsForUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUploadsForUser", reflect.TypeOf((*MockClient)(nil).GetUploadsForUser), arg0) +} + +// GetUser mocks base method. +func (m *MockClient) GetUser(arg0, arg1 string) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUser", arg0, arg1) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUser indicates an expected call of GetUser. +func (mr *MockClientMockRecorder) GetUser(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockClient)(nil).GetUser), arg0, arg1) +} + +// GetUserAccessTokensForUser mocks base method. +func (m *MockClient) GetUserAccessTokensForUser(arg0 string, arg1, arg2 int) ([]*model.UserAccessToken, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAccessTokensForUser", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.UserAccessToken) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUserAccessTokensForUser indicates an expected call of GetUserAccessTokensForUser. +func (mr *MockClientMockRecorder) GetUserAccessTokensForUser(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAccessTokensForUser", reflect.TypeOf((*MockClient)(nil).GetUserAccessTokensForUser), arg0, arg1, arg2) +} + +// GetUserByEmail mocks base method. +func (m *MockClient) GetUserByEmail(arg0, arg1 string) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByEmail", arg0, arg1) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUserByEmail indicates an expected call of GetUserByEmail. +func (mr *MockClientMockRecorder) GetUserByEmail(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockClient)(nil).GetUserByEmail), arg0, arg1) +} + +// GetUserByUsername mocks base method. +func (m *MockClient) GetUserByUsername(arg0, arg1 string) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByUsername", arg0, arg1) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUserByUsername indicates an expected call of GetUserByUsername. +func (mr *MockClientMockRecorder) GetUserByUsername(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockClient)(nil).GetUserByUsername), arg0, arg1) +} + +// GetUsers mocks base method. +func (m *MockClient) GetUsers(arg0, arg1 int, arg2 string) ([]*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsers", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUsers indicates an expected call of GetUsers. +func (mr *MockClientMockRecorder) GetUsers(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockClient)(nil).GetUsers), arg0, arg1, arg2) +} + +// GetUsersByIds mocks base method. +func (m *MockClient) GetUsersByIds(arg0 []string) ([]*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersByIds", arg0) + ret0, _ := ret[0].([]*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUsersByIds indicates an expected call of GetUsersByIds. +func (mr *MockClientMockRecorder) GetUsersByIds(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIds", reflect.TypeOf((*MockClient)(nil).GetUsersByIds), arg0) +} + +// GetUsersInTeam mocks base method. +func (m *MockClient) GetUsersInTeam(arg0 string, arg1, arg2 int, arg3 string) ([]*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersInTeam", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetUsersInTeam indicates an expected call of GetUsersInTeam. +func (mr *MockClientMockRecorder) GetUsersInTeam(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersInTeam", reflect.TypeOf((*MockClient)(nil).GetUsersInTeam), arg0, arg1, arg2, arg3) +} + +// InstallMarketplacePlugin mocks base method. +func (m *MockClient) InstallMarketplacePlugin(arg0 *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstallMarketplacePlugin", arg0) + ret0, _ := ret[0].(*model.Manifest) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// InstallMarketplacePlugin indicates an expected call of InstallMarketplacePlugin. +func (mr *MockClientMockRecorder) InstallMarketplacePlugin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallMarketplacePlugin", reflect.TypeOf((*MockClient)(nil).InstallMarketplacePlugin), arg0) +} + +// InstallPluginFromURL mocks base method. +func (m *MockClient) InstallPluginFromURL(arg0 string, arg1 bool) (*model.Manifest, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstallPluginFromURL", arg0, arg1) + ret0, _ := ret[0].(*model.Manifest) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// InstallPluginFromURL indicates an expected call of InstallPluginFromURL. +func (mr *MockClientMockRecorder) InstallPluginFromURL(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallPluginFromURL", reflect.TypeOf((*MockClient)(nil).InstallPluginFromURL), arg0, arg1) +} + +// InviteUsersToTeam mocks base method. +func (m *MockClient) InviteUsersToTeam(arg0 string, arg1 []string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InviteUsersToTeam", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InviteUsersToTeam indicates an expected call of InviteUsersToTeam. +func (mr *MockClientMockRecorder) InviteUsersToTeam(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteUsersToTeam", reflect.TypeOf((*MockClient)(nil).InviteUsersToTeam), arg0, arg1) +} + +// ListCommands mocks base method. +func (m *MockClient) ListCommands(arg0 string, arg1 bool) ([]*model.Command, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCommands", arg0, arg1) + ret0, _ := ret[0].([]*model.Command) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListCommands indicates an expected call of ListCommands. +func (mr *MockClientMockRecorder) ListCommands(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCommands", reflect.TypeOf((*MockClient)(nil).ListCommands), arg0, arg1) +} + +// ListExports mocks base method. +func (m *MockClient) ListExports() ([]string, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListExports") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListExports indicates an expected call of ListExports. +func (mr *MockClientMockRecorder) ListExports() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListExports", reflect.TypeOf((*MockClient)(nil).ListExports)) +} + +// ListImports mocks base method. +func (m *MockClient) ListImports() ([]string, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListImports") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListImports indicates an expected call of ListImports. +func (mr *MockClientMockRecorder) ListImports() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListImports", reflect.TypeOf((*MockClient)(nil).ListImports)) +} + +// MigrateAuthToLdap mocks base method. +func (m *MockClient) MigrateAuthToLdap(arg0, arg1 string, arg2 bool) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateAuthToLdap", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MigrateAuthToLdap indicates an expected call of MigrateAuthToLdap. +func (mr *MockClientMockRecorder) MigrateAuthToLdap(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateAuthToLdap", reflect.TypeOf((*MockClient)(nil).MigrateAuthToLdap), arg0, arg1, arg2) +} + +// MigrateAuthToSaml mocks base method. +func (m *MockClient) MigrateAuthToSaml(arg0 string, arg1 map[string]string, arg2 bool) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateAuthToSaml", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MigrateAuthToSaml indicates an expected call of MigrateAuthToSaml. +func (mr *MockClientMockRecorder) MigrateAuthToSaml(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateAuthToSaml", reflect.TypeOf((*MockClient)(nil).MigrateAuthToSaml), arg0, arg1, arg2) +} + +// MigrateConfig mocks base method. +func (m *MockClient) MigrateConfig(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateConfig", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MigrateConfig indicates an expected call of MigrateConfig. +func (mr *MockClientMockRecorder) MigrateConfig(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateConfig", reflect.TypeOf((*MockClient)(nil).MigrateConfig), arg0, arg1) +} + +// MigrateIdLdap mocks base method. +func (m *MockClient) MigrateIdLdap(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateIdLdap", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MigrateIdLdap indicates an expected call of MigrateIdLdap. +func (mr *MockClientMockRecorder) MigrateIdLdap(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateIdLdap", reflect.TypeOf((*MockClient)(nil).MigrateIdLdap), arg0) +} + +// MoveChannel mocks base method. +func (m *MockClient) MoveChannel(arg0, arg1 string, arg2 bool) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MoveChannel", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// MoveChannel indicates an expected call of MoveChannel. +func (mr *MockClientMockRecorder) MoveChannel(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MoveChannel", reflect.TypeOf((*MockClient)(nil).MoveChannel), arg0, arg1, arg2) +} + +// MoveCommand mocks base method. +func (m *MockClient) MoveCommand(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MoveCommand", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MoveCommand indicates an expected call of MoveCommand. +func (mr *MockClientMockRecorder) MoveCommand(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MoveCommand", reflect.TypeOf((*MockClient)(nil).MoveCommand), arg0, arg1) +} + +// PatchBot mocks base method. +func (m *MockClient) PatchBot(arg0 string, arg1 *model.BotPatch) (*model.Bot, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchBot", arg0, arg1) + ret0, _ := ret[0].(*model.Bot) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PatchBot indicates an expected call of PatchBot. +func (mr *MockClientMockRecorder) PatchBot(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBot", reflect.TypeOf((*MockClient)(nil).PatchBot), arg0, arg1) +} + +// PatchChannel mocks base method. +func (m *MockClient) PatchChannel(arg0 string, arg1 *model.ChannelPatch) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchChannel", arg0, arg1) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PatchChannel indicates an expected call of PatchChannel. +func (mr *MockClientMockRecorder) PatchChannel(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchChannel", reflect.TypeOf((*MockClient)(nil).PatchChannel), arg0, arg1) +} + +// PatchConfig mocks base method. +func (m *MockClient) PatchConfig(arg0 *model.Config) (*model.Config, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchConfig", arg0) + ret0, _ := ret[0].(*model.Config) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PatchConfig indicates an expected call of PatchConfig. +func (mr *MockClientMockRecorder) PatchConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchConfig", reflect.TypeOf((*MockClient)(nil).PatchConfig), arg0) +} + +// PatchRole mocks base method. +func (m *MockClient) PatchRole(arg0 string, arg1 *model.RolePatch) (*model.Role, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchRole", arg0, arg1) + ret0, _ := ret[0].(*model.Role) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PatchRole indicates an expected call of PatchRole. +func (mr *MockClientMockRecorder) PatchRole(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchRole", reflect.TypeOf((*MockClient)(nil).PatchRole), arg0, arg1) +} + +// PatchTeam mocks base method. +func (m *MockClient) PatchTeam(arg0 string, arg1 *model.TeamPatch) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchTeam", arg0, arg1) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PatchTeam indicates an expected call of PatchTeam. +func (mr *MockClientMockRecorder) PatchTeam(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchTeam", reflect.TypeOf((*MockClient)(nil).PatchTeam), arg0, arg1) +} + +// PermanentDeleteAllUsers mocks base method. +func (m *MockClient) PermanentDeleteAllUsers() (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentDeleteAllUsers") + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermanentDeleteAllUsers indicates an expected call of PermanentDeleteAllUsers. +func (mr *MockClientMockRecorder) PermanentDeleteAllUsers() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteAllUsers", reflect.TypeOf((*MockClient)(nil).PermanentDeleteAllUsers)) +} + +// PermanentDeleteChannel mocks base method. +func (m *MockClient) PermanentDeleteChannel(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentDeleteChannel", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermanentDeleteChannel indicates an expected call of PermanentDeleteChannel. +func (mr *MockClientMockRecorder) PermanentDeleteChannel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteChannel", reflect.TypeOf((*MockClient)(nil).PermanentDeleteChannel), arg0) +} + +// PermanentDeleteTeam mocks base method. +func (m *MockClient) PermanentDeleteTeam(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentDeleteTeam", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermanentDeleteTeam indicates an expected call of PermanentDeleteTeam. +func (mr *MockClientMockRecorder) PermanentDeleteTeam(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteTeam", reflect.TypeOf((*MockClient)(nil).PermanentDeleteTeam), arg0) +} + +// PermanentDeleteUser mocks base method. +func (m *MockClient) PermanentDeleteUser(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermanentDeleteUser", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermanentDeleteUser indicates an expected call of PermanentDeleteUser. +func (mr *MockClientMockRecorder) PermanentDeleteUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermanentDeleteUser", reflect.TypeOf((*MockClient)(nil).PermanentDeleteUser), arg0) +} + +// PromoteGuestToUser mocks base method. +func (m *MockClient) PromoteGuestToUser(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PromoteGuestToUser", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PromoteGuestToUser indicates an expected call of PromoteGuestToUser. +func (mr *MockClientMockRecorder) PromoteGuestToUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromoteGuestToUser", reflect.TypeOf((*MockClient)(nil).PromoteGuestToUser), arg0) +} + +// RegenOutgoingHookToken mocks base method. +func (m *MockClient) RegenOutgoingHookToken(arg0 string) (*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegenOutgoingHookToken", arg0) + ret0, _ := ret[0].(*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RegenOutgoingHookToken indicates an expected call of RegenOutgoingHookToken. +func (mr *MockClientMockRecorder) RegenOutgoingHookToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegenOutgoingHookToken", reflect.TypeOf((*MockClient)(nil).RegenOutgoingHookToken), arg0) +} + +// ReloadConfig mocks base method. +func (m *MockClient) ReloadConfig() (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReloadConfig") + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReloadConfig indicates an expected call of ReloadConfig. +func (mr *MockClientMockRecorder) ReloadConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadConfig", reflect.TypeOf((*MockClient)(nil).ReloadConfig)) +} + +// RemoveLicenseFile mocks base method. +func (m *MockClient) RemoveLicenseFile() (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveLicenseFile") + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveLicenseFile indicates an expected call of RemoveLicenseFile. +func (mr *MockClientMockRecorder) RemoveLicenseFile() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveLicenseFile", reflect.TypeOf((*MockClient)(nil).RemoveLicenseFile)) +} + +// RemovePlugin mocks base method. +func (m *MockClient) RemovePlugin(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePlugin", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemovePlugin indicates an expected call of RemovePlugin. +func (mr *MockClientMockRecorder) RemovePlugin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePlugin", reflect.TypeOf((*MockClient)(nil).RemovePlugin), arg0) +} + +// RemoveTeamMember mocks base method. +func (m *MockClient) RemoveTeamMember(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveTeamMember", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveTeamMember indicates an expected call of RemoveTeamMember. +func (mr *MockClientMockRecorder) RemoveTeamMember(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTeamMember", reflect.TypeOf((*MockClient)(nil).RemoveTeamMember), arg0, arg1) +} + +// RemoveUserFromChannel mocks base method. +func (m *MockClient) RemoveUserFromChannel(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveUserFromChannel", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveUserFromChannel indicates an expected call of RemoveUserFromChannel. +func (mr *MockClientMockRecorder) RemoveUserFromChannel(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveUserFromChannel", reflect.TypeOf((*MockClient)(nil).RemoveUserFromChannel), arg0, arg1) +} + +// ResetSamlAuthDataToEmail mocks base method. +func (m *MockClient) ResetSamlAuthDataToEmail(arg0, arg1 bool, arg2 []string) (int64, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetSamlAuthDataToEmail", arg0, arg1, arg2) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ResetSamlAuthDataToEmail indicates an expected call of ResetSamlAuthDataToEmail. +func (mr *MockClientMockRecorder) ResetSamlAuthDataToEmail(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetSamlAuthDataToEmail", reflect.TypeOf((*MockClient)(nil).ResetSamlAuthDataToEmail), arg0, arg1, arg2) +} + +// RestoreChannel mocks base method. +func (m *MockClient) RestoreChannel(arg0 string) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RestoreChannel", arg0) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RestoreChannel indicates an expected call of RestoreChannel. +func (mr *MockClientMockRecorder) RestoreChannel(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestoreChannel", reflect.TypeOf((*MockClient)(nil).RestoreChannel), arg0) +} + +// RestoreGroup mocks base method. +func (m *MockClient) RestoreGroup(arg0, arg1 string) (*model.Group, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RestoreGroup", arg0, arg1) + ret0, _ := ret[0].(*model.Group) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RestoreGroup indicates an expected call of RestoreGroup. +func (mr *MockClientMockRecorder) RestoreGroup(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestoreGroup", reflect.TypeOf((*MockClient)(nil).RestoreGroup), arg0, arg1) +} + +// RestoreTeam mocks base method. +func (m *MockClient) RestoreTeam(arg0 string) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RestoreTeam", arg0) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RestoreTeam indicates an expected call of RestoreTeam. +func (mr *MockClientMockRecorder) RestoreTeam(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestoreTeam", reflect.TypeOf((*MockClient)(nil).RestoreTeam), arg0) +} + +// RevokeUserAccessToken mocks base method. +func (m *MockClient) RevokeUserAccessToken(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RevokeUserAccessToken", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RevokeUserAccessToken indicates an expected call of RevokeUserAccessToken. +func (mr *MockClientMockRecorder) RevokeUserAccessToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockClient)(nil).RevokeUserAccessToken), arg0) +} + +// SearchTeams mocks base method. +func (m *MockClient) SearchTeams(arg0 *model.TeamSearch) ([]*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchTeams", arg0) + ret0, _ := ret[0].([]*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// SearchTeams indicates an expected call of SearchTeams. +func (mr *MockClientMockRecorder) SearchTeams(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchTeams", reflect.TypeOf((*MockClient)(nil).SearchTeams), arg0) +} + +// SendPasswordResetEmail mocks base method. +func (m *MockClient) SendPasswordResetEmail(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendPasswordResetEmail", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SendPasswordResetEmail indicates an expected call of SendPasswordResetEmail. +func (mr *MockClientMockRecorder) SendPasswordResetEmail(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendPasswordResetEmail", reflect.TypeOf((*MockClient)(nil).SendPasswordResetEmail), arg0) +} + +// SetServerBusy mocks base method. +func (m *MockClient) SetServerBusy(arg0 int) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetServerBusy", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetServerBusy indicates an expected call of SetServerBusy. +func (mr *MockClientMockRecorder) SetServerBusy(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetServerBusy", reflect.TypeOf((*MockClient)(nil).SetServerBusy), arg0) +} + +// SoftDeleteTeam mocks base method. +func (m *MockClient) SoftDeleteTeam(arg0 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteTeam", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SoftDeleteTeam indicates an expected call of SoftDeleteTeam. +func (mr *MockClientMockRecorder) SoftDeleteTeam(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteTeam", reflect.TypeOf((*MockClient)(nil).SoftDeleteTeam), arg0) +} + +// SyncLdap mocks base method. +func (m *MockClient) SyncLdap(arg0 bool) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncLdap", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SyncLdap indicates an expected call of SyncLdap. +func (mr *MockClientMockRecorder) SyncLdap(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncLdap", reflect.TypeOf((*MockClient)(nil).SyncLdap), arg0) +} + +// UpdateChannelPrivacy mocks base method. +func (m *MockClient) UpdateChannelPrivacy(arg0 string, arg1 model.ChannelType) (*model.Channel, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChannelPrivacy", arg0, arg1) + ret0, _ := ret[0].(*model.Channel) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateChannelPrivacy indicates an expected call of UpdateChannelPrivacy. +func (mr *MockClientMockRecorder) UpdateChannelPrivacy(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChannelPrivacy", reflect.TypeOf((*MockClient)(nil).UpdateChannelPrivacy), arg0, arg1) +} + +// UpdateCommand mocks base method. +func (m *MockClient) UpdateCommand(arg0 *model.Command) (*model.Command, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCommand", arg0) + ret0, _ := ret[0].(*model.Command) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateCommand indicates an expected call of UpdateCommand. +func (mr *MockClientMockRecorder) UpdateCommand(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCommand", reflect.TypeOf((*MockClient)(nil).UpdateCommand), arg0) +} + +// UpdateConfig mocks base method. +func (m *MockClient) UpdateConfig(arg0 *model.Config) (*model.Config, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateConfig", arg0) + ret0, _ := ret[0].(*model.Config) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateConfig indicates an expected call of UpdateConfig. +func (mr *MockClientMockRecorder) UpdateConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateConfig", reflect.TypeOf((*MockClient)(nil).UpdateConfig), arg0) +} + +// UpdateIncomingWebhook mocks base method. +func (m *MockClient) UpdateIncomingWebhook(arg0 *model.IncomingWebhook) (*model.IncomingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIncomingWebhook", arg0) + ret0, _ := ret[0].(*model.IncomingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateIncomingWebhook indicates an expected call of UpdateIncomingWebhook. +func (mr *MockClientMockRecorder) UpdateIncomingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIncomingWebhook", reflect.TypeOf((*MockClient)(nil).UpdateIncomingWebhook), arg0) +} + +// UpdateOutgoingWebhook mocks base method. +func (m *MockClient) UpdateOutgoingWebhook(arg0 *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOutgoingWebhook", arg0) + ret0, _ := ret[0].(*model.OutgoingWebhook) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateOutgoingWebhook indicates an expected call of UpdateOutgoingWebhook. +func (mr *MockClientMockRecorder) UpdateOutgoingWebhook(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOutgoingWebhook", reflect.TypeOf((*MockClient)(nil).UpdateOutgoingWebhook), arg0) +} + +// UpdateTeam mocks base method. +func (m *MockClient) UpdateTeam(arg0 *model.Team) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTeam", arg0) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateTeam indicates an expected call of UpdateTeam. +func (mr *MockClientMockRecorder) UpdateTeam(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeam", reflect.TypeOf((*MockClient)(nil).UpdateTeam), arg0) +} + +// UpdateTeamPrivacy mocks base method. +func (m *MockClient) UpdateTeamPrivacy(arg0, arg1 string) (*model.Team, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTeamPrivacy", arg0, arg1) + ret0, _ := ret[0].(*model.Team) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateTeamPrivacy indicates an expected call of UpdateTeamPrivacy. +func (mr *MockClientMockRecorder) UpdateTeamPrivacy(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeamPrivacy", reflect.TypeOf((*MockClient)(nil).UpdateTeamPrivacy), arg0, arg1) +} + +// UpdateUser mocks base method. +func (m *MockClient) UpdateUser(arg0 *model.User) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUser", arg0) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateUser indicates an expected call of UpdateUser. +func (mr *MockClientMockRecorder) UpdateUser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockClient)(nil).UpdateUser), arg0) +} + +// UpdateUserActive mocks base method. +func (m *MockClient) UpdateUserActive(arg0 string, arg1 bool) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserActive", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserActive indicates an expected call of UpdateUserActive. +func (mr *MockClientMockRecorder) UpdateUserActive(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserActive", reflect.TypeOf((*MockClient)(nil).UpdateUserActive), arg0, arg1) +} + +// UpdateUserHashedPassword mocks base method. +func (m *MockClient) UpdateUserHashedPassword(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserHashedPassword", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserHashedPassword indicates an expected call of UpdateUserHashedPassword. +func (mr *MockClientMockRecorder) UpdateUserHashedPassword(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserHashedPassword", reflect.TypeOf((*MockClient)(nil).UpdateUserHashedPassword), arg0, arg1) +} + +// UpdateUserMfa mocks base method. +func (m *MockClient) UpdateUserMfa(arg0, arg1 string, arg2 bool) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserMfa", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserMfa indicates an expected call of UpdateUserMfa. +func (mr *MockClientMockRecorder) UpdateUserMfa(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserMfa", reflect.TypeOf((*MockClient)(nil).UpdateUserMfa), arg0, arg1, arg2) +} + +// UpdateUserPassword mocks base method. +func (m *MockClient) UpdateUserPassword(arg0, arg1, arg2 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserPassword", arg0, arg1, arg2) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserPassword indicates an expected call of UpdateUserPassword. +func (mr *MockClientMockRecorder) UpdateUserPassword(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPassword", reflect.TypeOf((*MockClient)(nil).UpdateUserPassword), arg0, arg1, arg2) +} + +// UpdateUserRoles mocks base method. +func (m *MockClient) UpdateUserRoles(arg0, arg1 string) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserRoles", arg0, arg1) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserRoles indicates an expected call of UpdateUserRoles. +func (mr *MockClientMockRecorder) UpdateUserRoles(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserRoles", reflect.TypeOf((*MockClient)(nil).UpdateUserRoles), arg0, arg1) +} + +// UploadData mocks base method. +func (m *MockClient) UploadData(arg0 string, arg1 io.Reader) (*model.FileInfo, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadData", arg0, arg1) + ret0, _ := ret[0].(*model.FileInfo) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UploadData indicates an expected call of UploadData. +func (mr *MockClientMockRecorder) UploadData(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadData", reflect.TypeOf((*MockClient)(nil).UploadData), arg0, arg1) +} + +// UploadLicenseFile mocks base method. +func (m *MockClient) UploadLicenseFile(arg0 []byte) (*model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadLicenseFile", arg0) + ret0, _ := ret[0].(*model.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UploadLicenseFile indicates an expected call of UploadLicenseFile. +func (mr *MockClientMockRecorder) UploadLicenseFile(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadLicenseFile", reflect.TypeOf((*MockClient)(nil).UploadLicenseFile), arg0) +} + +// UploadPlugin mocks base method. +func (m *MockClient) UploadPlugin(arg0 io.Reader) (*model.Manifest, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadPlugin", arg0) + ret0, _ := ret[0].(*model.Manifest) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UploadPlugin indicates an expected call of UploadPlugin. +func (mr *MockClientMockRecorder) UploadPlugin(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadPlugin", reflect.TypeOf((*MockClient)(nil).UploadPlugin), arg0) +} + +// UploadPluginForced mocks base method. +func (m *MockClient) UploadPluginForced(arg0 io.Reader) (*model.Manifest, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadPluginForced", arg0) + ret0, _ := ret[0].(*model.Manifest) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UploadPluginForced indicates an expected call of UploadPluginForced. +func (mr *MockClientMockRecorder) UploadPluginForced(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadPluginForced", reflect.TypeOf((*MockClient)(nil).UploadPluginForced), arg0) +} + +// VerifyUserEmailWithoutToken mocks base method. +func (m *MockClient) VerifyUserEmailWithoutToken(arg0 string) (*model.User, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyUserEmailWithoutToken", arg0) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// VerifyUserEmailWithoutToken indicates an expected call of VerifyUserEmailWithoutToken. +func (mr *MockClientMockRecorder) VerifyUserEmailWithoutToken(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyUserEmailWithoutToken", reflect.TypeOf((*MockClient)(nil).VerifyUserEmailWithoutToken), arg0) +} diff --git a/server/cmd/mmctl/mocks/copyright.txt b/server/cmd/mmctl/mocks/copyright.txt new file mode 100644 index 0000000000..396a37c996 --- /dev/null +++ b/server/cmd/mmctl/mocks/copyright.txt @@ -0,0 +1,2 @@ +Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +See LICENSE.txt for license information. \ No newline at end of file diff --git a/server/cmd/mmctl/printer/human/entry.go b/server/cmd/mmctl/printer/human/entry.go new file mode 100644 index 0000000000..76473eb0e2 --- /dev/null +++ b/server/cmd/mmctl/printer/human/entry.go @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package human + +import ( + "fmt" + "strings" + "time" + + "github.com/mattermost/mattermost-server/server/public/shared/mlog" +) + +type LogEntry struct { + Time time.Time + Level string + Message string + Caller string + Fields []mlog.Field +} + +// Provide default string representation. Used by SimpleWriter +func (f LogEntry) String() string { + var sb strings.Builder + if !f.Time.IsZero() { + sb.WriteString(f.Time.Format(time.RFC3339Nano)) + sb.WriteRune(' ') + } + if f.Level != "" { + sb.WriteString(f.Level) + sb.WriteRune(' ') + } + if f.Caller != "" { + sb.WriteString(f.Caller) + sb.WriteRune(' ') + } + for _, field := range f.Fields { + sb.WriteString(field.Key) + sb.WriteRune('=') + sb.WriteString(fmt.Sprint(field.Interface)) + sb.WriteRune(' ') + } + if f.Message != "" { + // If the message is multiple lines, start the whole message on a new line + if strings.ContainsRune(f.Message, '\n') { + sb.WriteRune('\n') + } + sb.WriteString(f.Message) + } + + return sb.String() +} diff --git a/server/cmd/mmctl/printer/human/logrus_writer.go b/server/cmd/mmctl/printer/human/logrus_writer.go new file mode 100644 index 0000000000..77cd0abb3f --- /dev/null +++ b/server/cmd/mmctl/printer/human/logrus_writer.go @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package human + +import ( + "fmt" + "io" + "time" + + "github.com/sirupsen/logrus" +) + +type LogrusWriter struct { + logger *logrus.Logger +} + +func (w *LogrusWriter) Write(e LogEntry) { + if e.Level == "" { + fmt.Fprintln(w.logger.Out, e.Message) + return + } + + lvl, err := logrus.ParseLevel(e.Level) + if err != nil { + fmt.Fprintln(w.logger.Out, err) + lvl = logrus.TraceLevel + 1 // will invoke Println + } + + logger := w.logger.WithTime(e.Time) + + if e.Caller != "" { + // logrus has a system of reporting the caller, but there's no easy way to override it + logger = logger.WithField("caller", e.Caller) + } + + for _, field := range e.Fields { + logger = logger.WithField(field.Key, field.Interface) + } + + switch lvl { + case logrus.PanicLevel: + // Prevent panic from causing us to exit + defer func() { + _ = recover() + }() + logger.Panic(e.Message) + case logrus.FatalLevel: + logger.Fatal(e.Message) + case logrus.ErrorLevel: + logger.Error(e.Message) + case logrus.WarnLevel: + logger.Warn(e.Message) + case logrus.InfoLevel: + logger.Info(e.Message) + case logrus.DebugLevel: + logger.Debug(e.Message) + case logrus.TraceLevel: + logger.Trace(e.Message) + default: + logger.Println(e.Message) + } +} + +func NewLogrusWriter(output io.Writer) *LogrusWriter { + w := new(LogrusWriter) + w.logger = logrus.New() + w.logger.SetLevel(logrus.TraceLevel) // don't filter any logs + w.logger.ExitFunc = func(int) {} // prevent Fatal from causing us to exit + w.logger.SetReportCaller(false) + w.logger.SetOutput(output) + var tf logrus.TextFormatter + tf.FullTimestamp = true + tf.TimestampFormat = time.RFC3339Nano + w.logger.SetFormatter(&tf) + return w +} diff --git a/server/cmd/mmctl/printer/human/parser.go b/server/cmd/mmctl/printer/human/parser.go new file mode 100644 index 0000000000..241721b61d --- /dev/null +++ b/server/cmd/mmctl/printer/human/parser.go @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package human + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/mattermost/mattermost-server/server/public/shared/mlog" +) + +func ParseLogMessage(msg string) LogEntry { + result, err := parseLogMessage(msg) + if err != nil { + // If failed to parse, just output a LogEntry where all fields are blank, but Message is the original string + var result2 LogEntry + result2.Message = msg + return result2 + } + return result +} + +func parseLogMessage(msg string) (result LogEntry, err error) { + // Note: This implementation uses a custom json decoding loop. + // The primary advantage of this versus decoding directly into a map is to + // preserve the order of the fields. This can be simplified if we end up + // having the formatter sort fields alphabetically (logrus does by default) + + dec := json.NewDecoder(strings.NewReader(msg)) + + // look for an initial "{" + token, err := dec.Token() + if err != nil { + return result, err + } + d, ok := token.(json.Delim) + if !ok || d != '{' { + return result, fmt.Errorf("input is not a JSON object, found: %v", token) + } + + // read all key-value pairs + for dec.More() { + key, err2 := dec.Token() + if err2 != nil { + return result, err2 + } + skey, ok2 := key.(string) + if !ok2 { + return result, errors.New("key is not a value string") + } + if !dec.More() { + return result, errors.New("missing value pair") + } + + switch skey { + case "ts": + var ts json.Number + if err2 := dec.Decode(&ts); err2 != nil { + return result, err2 + } + timeVal, err2 := numberToTime(ts) + if err2 != nil { + return result, err2 + } + result.Time = timeVal + + case "level": + s, err2 := decodeAsString(dec) + if err2 != nil { + return result, err2 + } + result.Level = s + + case "msg": + s, err2 := decodeAsString(dec) + if err2 != nil { + return result, err2 + } + result.Message = s + + case "caller": + s, err2 := decodeAsString(dec) + if err2 != nil { + return result, err2 + } + result.Caller = s + + default: + var p interface{} + if err2 := dec.Decode(&p); err2 != nil { + return result, err2 + } + var f mlog.Field + f.Key = skey + f.Interface = p + result.Fields = append(result.Fields, f) + } + } + + // read the "}" + token, err = dec.Token() + if err != nil { + return result, err + } + d, ok = token.(json.Delim) + if !ok || d != '}' { + return result, fmt.Errorf("failed to read '}', read: %v", token) + } + + // make sure nothing else trailing + if token, err := dec.Token(); err != io.EOF { + return result, err + } else if token != nil { + return result, errors.New("found trailing data") + } + + return result, nil +} + +// Translate a number into a time +func numberToTime(v json.Number) (time.Time, error) { + // Using floating point math to extract the nanoseconds leads to a time that doesn't exactly match the input + // Instead, parse out the components from the string representation + + var t time.Time + + // First make sure it is a number... + flt, err := v.Float64() + if err != nil { + return t, err + } + + s := v.String() + + if strings.ContainsAny(s, "eE") { + // input is in scientific notation. Convert to standard decimal notation + s = strconv.FormatFloat(flt, 'f', -1, 64) + } + + // extract the seconds and nanoseconds separately + var nanos, sec int64 + + parts := strings.SplitN(s, ".", 2) + sec, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return t, err + } + + if len(parts) == 2 { + nanosText := parts[1] + "000000000" + nanosText = nanosText[:9] + nanos, err = strconv.ParseInt(nanosText, 10, 64) + if err != nil { + return t, err + } + } + + t = time.Unix(sec, nanos) + return t, nil +} + +// Decodes a value from JSON, coercing it to a string value as necessary +func decodeAsString(dec *json.Decoder) (s string, err error) { + var v interface{} + if err = dec.Decode(&v); err != nil { + return s, err + } + var ok bool + if s, ok = v.(string); ok { + return s, err + } + s = fmt.Sprint(v) + return s, err +} diff --git a/server/cmd/mmctl/printer/human/process.go b/server/cmd/mmctl/printer/human/process.go new file mode 100644 index 0000000000..ae31acdad3 --- /dev/null +++ b/server/cmd/mmctl/printer/human/process.go @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package human + +import ( + "bufio" + "io" +) + +type LogWriter interface { + Write(e LogEntry) +} + +// Read JSON logs from input and write formatted logs to the output +func ProcessLogs(reader io.Reader, writer LogWriter) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + s := scanner.Text() + e := ParseLogMessage(s) + writer.Write(e) + } +} diff --git a/server/cmd/mmctl/printer/human/simple_writer.go b/server/cmd/mmctl/printer/human/simple_writer.go new file mode 100644 index 0000000000..760ed91596 --- /dev/null +++ b/server/cmd/mmctl/printer/human/simple_writer.go @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package human + +import ( + "fmt" + "io" +) + +type SimpleWriter struct { + out io.Writer +} + +func (w *SimpleWriter) Write(e LogEntry) { + fmt.Fprintln(w.out, e) +} + +func NewSimpleWriter(out io.Writer) *SimpleWriter { + w := new(SimpleWriter) + w.out = out + return w +} diff --git a/server/cmd/mmctl/printer/keys.go b/server/cmd/mmctl/printer/keys.go new file mode 100644 index 0000000000..82c4fc54c5 --- /dev/null +++ b/server/cmd/mmctl/printer/keys.go @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package printer + +// These are the key that aliases +const ( + ArrowLeft = rune(KeyCtrlB) + ArrowRight = rune(KeyCtrlF) + ArrowUp = rune(KeyCtrlP) + ArrowDown = rune(KeyCtrlN) + Space = ' ' + Enter = '\r' + NewLine = '\n' + Backspace = rune(KeyCtrlH) + Backspace2 = rune(KeyDEL) +) + +// Key is the ascii codes of a keys +type Key int16 + +// These are the control keys. Note that they overlap with other keys. +const ( + KeyCtrlSpace Key = iota + KeyCtrlA // KeySOH + KeyCtrlB // KeySTX + KeyCtrlC // KeyETX + KeyCtrlD // KeyEOT + KeyCtrlE // KeyENQ + KeyCtrlF // KeyACK + KeyCtrlG // KeyBEL + KeyCtrlH // KeyBS + KeyCtrlI // KeyTAB + KeyCtrlJ // KeyLF + KeyCtrlK // KeyVT + KeyCtrlL // KeyFF + KeyCtrlM // KeyCR + KeyCtrlN // KeySO + KeyCtrlO // KeySI + KeyCtrlP // KeyDLE + KeyCtrlQ // KeyDC1 + KeyCtrlR // KeyDC2 + KeyCtrlS // KeyDC3 + KeyCtrlT // KeyDC4 + KeyCtrlU // KeyNAK + KeyCtrlV // KeySYN + KeyCtrlW // KeyETB + KeyCtrlX // KeyCAN + KeyCtrlY // KeyEM + KeyCtrlZ // KeySUB + KeyESC // KeyESC + KeyCtrlBackslash // KeyFS + KeyCtrlRightSq // KeyGS + KeyCtrlCarat // KeyRS + KeyCtrlUnderscore // KeyUS + KeyDEL = 0x7F +) diff --git a/server/cmd/mmctl/printer/printer.go b/server/cmd/mmctl/printer/printer.go new file mode 100644 index 0000000000..e5a1c49800 --- /dev/null +++ b/server/cmd/mmctl/printer/printer.go @@ -0,0 +1,325 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package printer + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "text/template" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +const ( + FormatPlain = "plain" + FormatJSON = "json" +) + +type Printer struct { //nolint + writer io.Writer + eWriter io.Writer + + Format string + Single bool + NoNewline bool + templateFuncs template.FuncMap + pager bool + Quiet bool + Lines []interface{} + ErrorLines []interface{} + + cmd *cobra.Command + serverAddr string +} + +type printOpts struct { + format string + pagerPath string + single bool + usePager bool + shortStat bool + noNewline bool +} + +var printer Printer + +func init() { + printer.writer = os.Stdout + printer.eWriter = os.Stderr + printer.pager = true + printer.templateFuncs = make(template.FuncMap) +} + +// SetFormat sets the format for the final output of the printer +func SetFormat(t string) { + printer.Format = t +} + +func SetCommand(cmd *cobra.Command) { + printer.cmd = cmd +} + +func SetServerAddres(addr string) { + printer.serverAddr = addr +} + +func OverrideEnablePager(enable bool) { + printer.pager = enable +} + +// SetFormat sets the format for the final output of the printer +func SetQuiet(q bool) { + printer.Quiet = q +} + +// SetNoNewline prevents the addition of a newline at the end of the plain output. +// This may be useful when you want to handle newlines yourself. +func SetNoNewline(no bool) { + printer.NoNewline = no +} + +func SetTemplateFunc(name string, f interface{}) { + printer.templateFuncs[name] = f +} + +// SetSingle sets the single flag on the printer. If this flag is set, the +// printer will check the size of stored elements before printing, and +// if there is only one, it will be printed on its own instead of +// inside a list +func SetSingle(single bool) { + printer.Single = single +} + +// PrintT prints an element. Depending on the format, the element can be +// formatted and printed as a structure or used to populate the +// template +func PrintT(templateString string, v interface{}) { + if printer.Quiet { + return + } + switch printer.Format { + case FormatPlain: + tpl := template.Must(template.New("").Funcs(printer.templateFuncs).Parse(templateString)) + sb := &strings.Builder{} + if err := tpl.Execute(sb, v); err != nil { + PrintError("Can't print the message using the provided template: " + templateString) + return + } + printer.Lines = append(printer.Lines, sb.String()) + case FormatJSON: + printer.Lines = append(printer.Lines, v) + } +} + +func PrintPreparedT(tpl *template.Template, v interface{}) { + if printer.Quiet { + return + } + switch printer.Format { + case FormatPlain: + sb := &strings.Builder{} + if err := tpl.Execute(sb, v); err != nil { + PrintError("Can't print the message using the provided template: " + err.Error()) + return + } + printer.Lines = append(printer.Lines, sb.String()) + case FormatJSON: + printer.Lines = append(printer.Lines, v) + } +} + +// Print an element. If the format requires a template, the element +// will be printed as a structure with field names using the print +// verb %+v +func Print(v interface{}) { + PrintT("{{printf \"%+v\" .}}", v) +} + +// Flush writes the elements accumulated in the printer +func Flush() error { + if printer.Quiet { + return nil + } + + opts := printOpts{ + format: printer.Format, + single: printer.Single, + noNewline: printer.NoNewline, + } + + cmd := printer.cmd + if cmd != nil { + shortStat, err := printer.cmd.Flags().GetBool("short-stat") + if err == nil && printer.cmd.Name() == "list" && printer.cmd.Parent().Name() != "auth" { + opts.shortStat = shortStat + } + } + + b, err := printer.linesToBytes(opts) + if err != nil { + return err + } + lines := lineCount(b) + + isTTY := checkInteractiveTerminal() == nil + var enablePager bool + termHeight, err := termHeight(os.Stdout) + if err == nil { + enablePager = isTTY && (termHeight < lines) // calculate if we should enable paging + } + + pager := os.Getenv("PAGER") + if enablePager { + enablePager = pager != "" + } + + opts.usePager = enablePager && printer.pager + opts.pagerPath = pager + + err = printer.printBytes(b, opts) + if err != nil { + return err + } + + // after all, print errors + printer.printErrors() + + defer func() { + printer.Lines = []interface{}{} + printer.ErrorLines = []interface{}{} + }() + + if cmd == nil || cmd.Name() != "list" || printer.cmd.Parent().Name() == "auth" { + return nil + } + + // the command is a list command, we may want to + // take care of the stat flags + noStat, err := cmd.Flags().GetBool("no-stat") + if err != nil { + return err + } + + // print stats + switch { + case noStat: + // do nothing + case !opts.shortStat: + // should not go to pager + if isTTY && !enablePager { + fmt.Fprintf(printer.eWriter, "\n") // add a one line space before statistical data + } + fallthrough + case len(printer.Lines) > 0: + entity := cmd.Parent().Name() + container := strings.TrimSuffix(printer.serverAddr, "api/v4") + if container != "" { + container = fmt.Sprintf(" on %s", container) + } + fmt.Fprintf(printer.eWriter, "There are %d %ss%s\n", len(printer.Lines), entity, container) + } + + return nil +} + +// Clean resets the printer's accumulated lines +func Clean() { + printer.Lines = []interface{}{} + printer.ErrorLines = []interface{}{} +} + +// GetLines returns the printer's accumulated lines +func GetLines() []interface{} { + return printer.Lines +} + +// GetErrorLines returns the printer's accumulated error lines +func GetErrorLines() []interface{} { + return printer.ErrorLines +} + +// PrintError prints to the stderr. +func PrintError(msg string) { + printer.ErrorLines = append(printer.ErrorLines, msg) +} + +// PrintWarning prints warning message to the error output, unlike Print and PrintError +// functions, PrintWarning writes the output immediately instead of waiting command to finish. +func PrintWarning(msg string) { + if printer.Quiet { + return + } + fmt.Fprintf(printer.eWriter, "%s\n", color.YellowString("WARNING: %s", msg)) +} + +func (p Printer) linesToBytes(opts printOpts) (b []byte, err error) { + if opts.shortStat { + return + } + + newline := "\n" + if opts.noNewline { + newline = "" + } + + switch opts.format { + case FormatPlain: + var buf bytes.Buffer + for i := range p.Lines { + fmt.Fprintf(&buf, "%s%s", p.Lines[i], newline) + } + b = buf.Bytes() + case FormatJSON: + switch { + case opts.single && len(p.Lines) == 0: + return + case opts.single && len(p.Lines) == 1: + b, err = json.MarshalIndent(p.Lines[0], "", " ") + default: + b, err = json.MarshalIndent(p.Lines, "", " ") + } + b = append(b, '\n') + } + return +} + +func (p Printer) printBytes(b []byte, opts printOpts) error { + if !opts.usePager { + fmt.Fprintf(p.writer, "%s", b) + return nil + } + + c := exec.Command(opts.pagerPath) // nolint:gosec + + in, err := c.StdinPipe() + if err != nil { + return fmt.Errorf("could not create the stdin pipe: %w", err) + } + + c.Stdout = p.writer + c.Stderr = p.eWriter + + go func() { + defer in.Close() + _, _ = io.Copy(in, bytes.NewReader(b)) + }() + + if err := c.Start(); err != nil { + return fmt.Errorf("could not start the pager: %w", err) + } + + return c.Wait() +} + +func (p Printer) printErrors() { + for i := range printer.ErrorLines { + fmt.Fprintln(printer.eWriter, printer.ErrorLines[i]) + } +} diff --git a/server/cmd/mmctl/printer/printer_test.go b/server/cmd/mmctl/printer/printer_test.go new file mode 100644 index 0000000000..fa63472ecd --- /dev/null +++ b/server/cmd/mmctl/printer/printer_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package printer + +import ( + "bufio" + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +type mockWriter []byte + +func (w *mockWriter) Write(b []byte) (n int, err error) { + *w = append(*w, b...) + return len(*w) - len(b), nil +} + +func TestPrintT(t *testing.T) { + w := bufio.NewWriter(&bytes.Buffer{}) + printer.writer = w + printer.Format = FormatPlain + + ts := struct { + ID int + }{ + ID: 123, + } + + t.Run("should execute template", func(t *testing.T) { + tpl := `testing template {{.ID}}` + PrintT(tpl, ts) + assert.Len(t, GetLines(), 1) + + assert.Equal(t, "testing template 123", printer.Lines[0]) + + _ = Flush() + }) + + t.Run("should fail to execute, no method or field", func(t *testing.T) { + Clean() + tpl := `testing template {{.Name}}` + PrintT(tpl, ts) + assert.Len(t, GetErrorLines(), 1) + + assert.Equal(t, "Can't print the message using the provided template: "+tpl, printer.ErrorLines[0]) + _ = Flush() + }) +} + +func TestPrintPreparedT(t *testing.T) { + w := bufio.NewWriter(&bytes.Buffer{}) + printer.writer = w + printer.Format = FormatPlain + + ts := struct { + ID int + }{ + ID: 123, + } + + t.Run("should execute template", func(t *testing.T) { + tpl := template.Must(template.New("").Parse(`testing template {{.ID}}`)) + PrintPreparedT(tpl, ts) + assert.Len(t, GetLines(), 1) + + assert.Equal(t, "testing template 123", printer.Lines[0]) + + _ = Flush() + }) + + t.Run("should fail to execute, no method or field", func(t *testing.T) { + Clean() + tpl := template.Must(template.New("").Parse(`testing template {{.Name}}`)) + PrintPreparedT(tpl, ts) + assert.Len(t, GetErrorLines(), 1) + + assert.Contains(t, printer.ErrorLines[0], "Can't print the message using the provided template") + _ = Flush() + }) +} + +func TestFlushJSON(t *testing.T) { + printer.Format = FormatJSON + + t.Run("should print a line in JSON format", func(t *testing.T) { + mw := &mockWriter{} + printer.writer = mw + Clean() + + Print("test string") + assert.Len(t, GetLines(), 1) + + _ = Flush() + assert.Equal(t, "[\n \"test string\"\n]\n", string(*mw)) + assert.Empty(t, GetLines(), 0) + }) + + t.Run("should print multi line in JSON format", func(t *testing.T) { + mw := &mockWriter{} + printer.writer = mw + + Clean() + Print("test string-1") + Print("test string-2") + assert.Len(t, GetLines(), 2) + + _ = Flush() + assert.Equal(t, "[\n \"test string-1\",\n \"test string-2\"\n]\n", string(*mw)) + assert.Empty(t, GetLines(), 0) + }) +} + +func TestFlushPlain(t *testing.T) { + printer.Format = FormatPlain + + t.Run("should print a line in plain format", func(t *testing.T) { + mw := &mockWriter{} + printer.writer = mw + Clean() + + Print("test string") + assert.Len(t, GetLines(), 1) + + _ = Flush() + assert.Equal(t, "test string\n", string(*mw)) + assert.Empty(t, GetLines(), 0) + }) + + t.Run("should print multi line in plain format", func(t *testing.T) { + mw := &mockWriter{} + printer.writer = mw + + Clean() + Print("test string-1") + Print("test string-2") + assert.Len(t, GetLines(), 2) + + _ = Flush() + assert.Equal(t, "test string-1\ntest string-2\n", string(*mw)) + assert.Empty(t, GetLines(), 0) + }) + + t.Run("should print multi line in plain format without a newline", func(t *testing.T) { + mw := &mockWriter{} + printer.writer = mw + printer.NoNewline = true + + Clean() + Print("test string-1") + Print("test string-2") + assert.Len(t, GetLines(), 2) + + _ = Flush() + assert.Equal(t, "test string-1test string-2", string(*mw)) + assert.Empty(t, GetLines(), 0) + }) +} diff --git a/server/cmd/mmctl/printer/util.go b/server/cmd/mmctl/printer/util.go new file mode 100644 index 0000000000..eca1b9dc50 --- /dev/null +++ b/server/cmd/mmctl/printer/util.go @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package printer + +import ( + "bytes" + "errors" + "os" + + "golang.org/x/term" +) + +func checkInteractiveTerminal() error { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return err + } + + if (fileInfo.Mode() & os.ModeCharDevice) == 0 { + return errors.New("this is not an interactive shell") + } + + return nil +} + +func termHeight(file *os.File) (int, error) { + _, h, err := term.GetSize(int(file.Fd())) + if err != nil { + return -1, err + } + + return h, nil +} + +func lineCount(b []byte) int { + return bytes.Count(b, []byte{'\n'}) +} diff --git a/server/go.mod b/server/go.mod index ad3b15908e..6aca1dae96 100644 --- a/server/go.mod +++ b/server/go.mod @@ -15,6 +15,7 @@ require ( github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 github.com/disintegration/imaging v1.6.2 github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a + github.com/fatih/color v1.15.0 github.com/fsnotify/fsnotify v1.6.0 github.com/getsentry/sentry-go v0.20.0 github.com/go-sql-driver/mysql v1.7.1 @@ -30,13 +31,20 @@ require ( github.com/graph-gophers/dataloader/v7 v7.1.0 github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b + github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/memberlist v0.3.1 + github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 + github.com/isacikgoz/prompt v0.1.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/jmoiron/sqlx v1.3.5 github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 github.com/lib/pq v1.10.9 github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 + github.com/mattermost/gosaml2 v0.3.3 github.com/mattermost/gziphandler v0.0.1 + github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d github.com/mattermost/logr/v2 v2.0.16 github.com/mattermost/mattermost-server/server/public v0.0.0-00010101000000-000000000000 github.com/mattermost/morph v1.0.5-0.20230511171014-e76e25978d56 @@ -65,6 +73,7 @@ require ( github.com/stretchr/testify v1.8.2 github.com/throttled/throttled v2.2.5+incompatible github.com/tinylib/msgp v1.1.8 + github.com/tylerb/graceful v1.2.15 github.com/uber/jaeger-client-go v2.30.0+incompatible github.com/uber/jaeger-lib v2.4.1+incompatible github.com/vmihailenco/msgpack/v5 v5.3.5 @@ -77,9 +86,11 @@ require ( golang.org/x/net v0.10.0 golang.org/x/oauth2 v0.7.0 golang.org/x/sync v0.2.0 + golang.org/x/term v0.8.0 golang.org/x/tools v0.9.1 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/mail.v2 v2.3.1 + gopkg.in/olivere/elastic.v6 v6.2.37 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -93,7 +104,9 @@ require ( github.com/andybalholm/brotli v1.0.5 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/armon/go-metrics v0.4.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.5.0 // indirect github.com/bits-and-blooms/bloom/v3 v3.3.1 // indirect @@ -112,13 +125,15 @@ require ( github.com/blevesearch/zapx/v13 v13.3.7 // indirect github.com/blevesearch/zapx/v14 v14.3.7 // indirect github.com/blevesearch/zapx/v15 v15.3.9 // indirect + github.com/corpix/uarand v0.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.15.0 // indirect github.com/fatih/set v0.2.1 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/fortytw2/leaktest v1.3.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect @@ -127,16 +142,24 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/google/btree v1.0.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-msgpack v1.1.5 // indirect github.com/hashicorp/go-plugin v1.4.9 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/isacikgoz/fuzzy v0.2.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jonboulle/clockwork v0.2.3 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.16.4 // indirect @@ -146,11 +169,13 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/miekg/dns v1.1.48 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -159,6 +184,7 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/olivere/elastic v6.2.37+incompatible // indirect github.com/otiai10/gosseract/v2 v2.4.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect @@ -174,6 +200,9 @@ require ( github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.3 // indirect github.com/rs/xid v1.4.0 // indirect + github.com/russellhaering/goxmldsig v1.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/backo-go v1.0.1 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/server/go.sum b/server/go.sum index 76438f62ff..a8e16e2245 100644 --- a/server/go.sum +++ b/server/go.sum @@ -89,15 +89,19 @@ github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBp github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ClickHouse/clickhouse-go v1.4.3/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/HdrHistogram/hdrhistogram-go v0.9.0 h1:dpujRju0R4M/QZzcnR1LH1qm+TVG3UzkWdp5tH1WMcg= github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4= github.com/JalfResi/justext v0.0.0-20170829062021-c0282dea7198/go.mod h1:0SURuH1rsE8aVWvutuMZghRNrNrYEUzibzJfhEYR8L0= github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052 h1:8T2zMbhLBbH9514PIQVHdsGhypMrsB4CxwbldKA9sBA= github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052/go.mod h1:0SURuH1rsE8aVWvutuMZghRNrNrYEUzibzJfhEYR8L0= +github.com/Masterminds/glide v0.13.2/go.mod h1:STyF5vcenH/rUqTEv+/hBXlSTo7KYwg2oc2f4tzPWic= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= @@ -165,6 +169,8 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoU github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.0 h1:yCQqn7dwca4ITXb+CbubHmedzaQYHhNhrEXLYUeEe8Q= +github.com/armon/go-metrics v0.4.0/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1 h1:9h8f71kuF1pqovnn9h7LTHLEjxzyQaj0j1rQq5nsMM4= @@ -201,6 +207,8 @@ github.com/aws/smithy-go v1.7.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAm github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -289,6 +297,8 @@ github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -307,6 +317,7 @@ github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:z github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= +github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA= github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE= github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU= github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= @@ -422,8 +433,11 @@ github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/corpix/uarand v0.1.1 h1:RMr1TWc9F4n5jiPDzFHtmaUXLKLNUFK0SgCLo4BhX/U= +github.com/corpix/uarand v0.1.1/go.mod h1:SFKZvkcRoLqVRFZ4u25xPmp6m9ktANfbpXZ7SJ0/FNU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -496,6 +510,7 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= @@ -510,6 +525,8 @@ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzP github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.5+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -684,6 +701,7 @@ github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNu github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/flatbuffers v2.0.0+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -785,27 +803,38 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFb github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c h1:fEE5/5VNnYUoBOj2I9TP8Jc+a7lge3QWn9DKE7NCwfc= github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c/go.mod h1:ObS/W+h8RYb1Y7fYivughjxojTmIu5iAIjSrSLCLeqE= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs= +github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4= github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU= github.com/hashicorp/go-plugin v1.4.9/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -817,6 +846,8 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.3.1 h1:MXgUXLqva1QvpVEDQW1IQLG0wivQAtmFlHRQ+1vWZfM= +github.com/hashicorp/memberlist v0.3.1/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= @@ -824,6 +855,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 h1:Mo9W14pwbO9VfRe+ygqZ8dFbPpoIK1HFrG/zjTuQ+nc= +github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428/go.mod h1:uhpZMVGznybq1itEKXj6RYw9I71qK4kH+OGMjRC4KEo= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -833,6 +866,10 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/intel/goresctrl v0.2.0/go.mod h1:+CZdzouYFn5EsxgqAQTEzMfwKwuc0fVdMrT9FCCAVRQ= +github.com/isacikgoz/fuzzy v0.2.0 h1:b2AUOLrmR36em9UhkWMkIrEJZFeoPgl9kZzBiktpntU= +github.com/isacikgoz/fuzzy v0.2.0/go.mod h1:VEYn1Gfwj4lMg+FTH603LmQni/zTrhxKv7nTFG+RO8U= +github.com/isacikgoz/prompt v0.1.0 h1:fv6jBpM0TNjypC66XuyyzD67dAerIjPzxVAj5WQwn/8= +github.com/isacikgoz/prompt v0.1.0/go.mod h1:4wlyaxU1qSotYuMZm8vcy1/tGGMfCU1wMjOnXZc58z0= github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= github.com/j-keck/arping v1.0.2/go.mod h1:aJbELhR92bSk7tp79AWM/ftfc90EfEi2bQJrbBFOsPw= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -901,10 +938,14 @@ github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52Cu github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.2.3 h1:N1FyPPFU62shxAPCrBvOMoqlr6gt7bAYKDASFu+JFaE= +github.com/jonboulle/clockwork v0.2.3/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -953,6 +994,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= @@ -963,6 +1005,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 h1:+AIlO01SKT9sfWU5CLWi0cfHc7dQwgGz3FhFRzXLoMg= github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94/go.mod h1:TcE3PIIkVWbP/HjhRAafgCjRKvDOi086iqp9VkNX/ng= github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= @@ -992,12 +1036,16 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404/go.mod h1:RyS7FDNQlzF1PsjbJWHRI35exqaKGSO9qD4iv8QjE34= +github.com/mattermost/gosaml2 v0.3.3 h1:ysWrjp08tpWmo6rV2MQ88Eag/Ttjuj91fZsvibF4/Tg= +github.com/mattermost/gosaml2 v0.3.3/go.mod h1:Z429EIOiEi9kbq6yHoApfzlcXpa6dzRDc6pO+Vy2Ksk= github.com/mattermost/gziphandler v0.0.1 h1:uXHcXF5agnQ6bXabvpiwwwZOlCYoa7mKHH0lxns/o8w= github.com/mattermost/gziphandler v0.0.1/go.mod h1:CvvZR7sXqhj81V2swXuQY7T04Ccc89u7W7pHNPKev8g= github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d h1:/RJ/UV7M5c7L2TQ0KNm4yZxxFvC1nvRz/gY/Daa35aI= @@ -1014,6 +1062,7 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -1043,7 +1092,8 @@ github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lL github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= @@ -1057,6 +1107,9 @@ github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00v github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.48 h1:Ucfr7IIVyMBz4lRE8qmGUuZ4Wt3/ZGu9hmcMT3Uu4tQ= +github.com/miekg/dns v1.1.48/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= @@ -1071,6 +1124,7 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -1113,6 +1167,7 @@ github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI= +github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= @@ -1127,6 +1182,8 @@ github.com/olekukonko/tablewriter v0.0.0-20180506121414-d4647c9c7a84/go.mod h1:v github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/olivere/elastic v6.2.37+incompatible h1:UfSGJem5czY+x/LqxgeCBgjDn6St+z8OnsCuxwD3L0U= +github.com/olivere/elastic v6.2.37+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -1190,6 +1247,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9 github.com/otiai10/mint v1.3.3 h1:7JgpsBaN0uMkyju4tbYHu0mnM55hNKVYLsXmwr15NQI= github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -1214,6 +1273,7 @@ github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1231,6 +1291,7 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= @@ -1248,6 +1309,7 @@ github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7q github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= @@ -1292,7 +1354,9 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo= github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= @@ -1302,19 +1366,24 @@ github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OK github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY= github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30= +github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg= +github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= @@ -1451,9 +1520,12 @@ github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkC github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/tylerb/graceful v1.2.15 h1:B0x01Y8fsJpogzZTkDg6BDi6eMf03s01lEKGdrv83oA= +github.com/tylerb/graceful v1.2.15/go.mod h1:LPYTbOYmUTdabwRt0TGhLllQ0MUNbs0Y5q1WXJOI9II= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= @@ -1596,6 +1668,7 @@ golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -1695,6 +1768,7 @@ golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1729,6 +1803,7 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1820,6 +1895,8 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1932,6 +2009,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1972,6 +2051,7 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -1985,6 +2065,7 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190706070813-72ffa07ba3db/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -2036,6 +2117,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -2267,6 +2349,8 @@ gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3M gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/olivere/elastic.v6 v6.2.37 h1:y1SqAL8MJvKckEOo3aZ+Ie0TDIYjrItZ9WBN3VzhoRM= +gopkg.in/olivere/elastic.v6 v6.2.37/go.mod h1:2cTT8Z+/LcArSWpCgvZqBgt3VOqXiy7v00w12Lz8bd4= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/server/scripts/download_mmctl_release.sh b/server/scripts/download_mmctl_release.sh deleted file mode 100755 index 84c5e9f313..0000000000 --- a/server/scripts/download_mmctl_release.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# $1 - version to download - -if [[ "$OS" = "Windows_NT" ]] -then - PLATFORM="Windows" -else - PLATFORM=$(uname)-$(uname -m) -fi - -if [[ ! -z "$1" ]]; -then - OVERRIDE_OS=$1 -fi - -BIN_PATH=${2:-bin} - - -## If pattern release-X.Y exist in branch name we parse the release branch and we fallback to masterx -THIS_BRANCH=$(git rev-parse --abbrev-ref HEAD) -RELEASE_PATTERN='release-[0-9]+\.[0-9]+(\.[0-9]+)?' -if [[ $THIS_BRANCH =~ $RELEASE_PATTERN ]]; then - RELEASE_TO_DOWNLOAD=$(echo $THIS_BRANCH | grep -Eo 'release-([0-9]+)?\.([0-9]+)?') -else - RELEASE_TO_DOWNLOAD="master" -fi - -echo "Downloading prepackaged binary: https://releases.mattermost.com/mmctl/$RELEASE_TO_DOWNLOAD"; - -# When packaging we need to download different platforms -# Values need to match the case statement below -if [[ ! -z "$OVERRIDE_OS" ]]; -then - PLATFORM="$OVERRIDE_OS" -fi - -case "$PLATFORM" in - -Linux-x86_64) - MMCTL_FILE="linux_amd64.tar" && curl -f -O -L https://releases.mattermost.com/mmctl/"$RELEASE_TO_DOWNLOAD"/"$MMCTL_FILE" && tar -xvf "$MMCTL_FILE" -C "$BIN_PATH" && rm "$MMCTL_FILE"; - ;; - -Linux-aarch64) - MMCTL_FILE="linux_arm64.tar" && curl -f -O -L https://releases.mattermost.com/mmctl/"$RELEASE_TO_DOWNLOAD"/"$MMCTL_FILE" && tar -xvf "$MMCTL_FILE" -C "$BIN_PATH" && rm "$MMCTL_FILE"; - ;; - -Darwin-x86_64) - MMCTL_FILE="darwin_amd64.tar" && curl -f -O -L https://releases.mattermost.com/mmctl/"$RELEASE_TO_DOWNLOAD"/"$MMCTL_FILE" && tar -xvf "$MMCTL_FILE" -C "$BIN_PATH" && rm "$MMCTL_FILE"; - ;; - -Darwin-arm64) - MMCTL_FILE="darwin_arm64.tar" && curl -f -O -L https://releases.mattermost.com/mmctl/"$RELEASE_TO_DOWNLOAD"/"$MMCTL_FILE" && tar -xvf "$MMCTL_FILE" -C "$BIN_PATH" && rm "$MMCTL_FILE"; - ;; - -Windows) - MMCTL_FILE="windows_amd64.zip" && curl -f -O -L https://releases.mattermost.com/mmctl/"$RELEASE_TO_DOWNLOAD"/"$MMCTL_FILE" && unzip -o "$MMCTL_FILE" -d "$BIN_PATH" && rm "$MMCTL_FILE"; - ;; - -*) - echo "error downloading mmctl: can't detect OS"; - ;; - -esac