diff --git a/e2e-tests/playwright/specs/functional/channels/mobile_logs_command.spec.ts b/e2e-tests/playwright/specs/functional/channels/mobile_logs_command.spec.ts new file mode 100644 index 00000000000..bad2c1cec70 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/mobile_logs_command.spec.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * Helper to find the attach_app_logs preference for a given user. + */ +async function getAttachLogsPreference( + userClient: { + getUserPreferences: (userId: string) => Promise>; + }, + userId: string, +) { + const prefs = await userClient.getUserPreferences(userId); + return prefs.find( + (p: {category: string; name: string}) => p.category === 'advanced_settings' && p.name === 'attach_app_logs', + ); +} + +test.describe('/mobile-logs slash command', () => { + /** + * @objective Verify that /mobile-logs on sets the attach_app_logs preference to true + * and returns an ephemeral confirmation visible only to the invoking user. + */ + test( + 'MM-T67880 enables mobile logs for self and confirms preference is set', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup + const {team, user, userClient} = await pw.initSetup(); + + // # Log in as the user and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs on + await channelsPage.postMessage('/mobile-logs on'); + + // * Verify ephemeral response confirms logs enabled + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Mobile app log attachment is now enabled for you'); + + // * Verify the response is ephemeral (only visible to the user) + await expect(lastPost.container.locator('.post__visibility')).toContainText('(Only visible to you)'); + + // * Verify the preference was actually set via API + const logPref = await getAttachLogsPreference(userClient, user.id); + expect(logPref).toBeDefined(); + expect(logPref!.value).toBe('true'); + }, + ); + + /** + * @objective Verify that /mobile-logs off sets the attach_app_logs preference to false + * and returns an ephemeral confirmation. + */ + test('MM-T67880 disables mobile logs for self after enabling via API', {tag: '@slash_commands'}, async ({pw}) => { + // # Initialize setup + const {team, user, userClient} = await pw.initSetup(); + + // # Pre-enable the preference via API to avoid back-to-back commands + await userClient.savePreferences(user.id, [ + {user_id: user.id, category: 'advanced_settings', name: 'attach_app_logs', value: 'true'}, + ]); + + // # Log in as the user and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs off + await channelsPage.postMessage('/mobile-logs off'); + + // * Verify ephemeral response confirms logs disabled + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Mobile app log attachment is now disabled for you'); + + // * Verify the preference was set to false via API + const logPref = await getAttachLogsPreference(userClient, user.id); + expect(logPref).toBeDefined(); + expect(logPref!.value).toBe('false'); + }); + + /** + * @objective Verify that /mobile-logs status reports disabled by default for a fresh user. + */ + test( + 'MM-T67880 reports mobile logs status as disabled for a fresh user', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup + const {team, user} = await pw.initSetup(); + + // # Log in as the user and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs status + await channelsPage.postMessage('/mobile-logs status'); + + // * Verify ephemeral response shows disabled + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Mobile app log attachment is currently disabled for you'); + }, + ); + + /** + * @objective Verify that /mobile-logs status reports enabled after the preference + * has been set to true via API. + */ + test( + 'MM-T67880 reports mobile logs status as enabled after preference is set', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup + const {team, user, userClient} = await pw.initSetup(); + + // # Pre-enable the preference via API + await userClient.savePreferences(user.id, [ + {user_id: user.id, category: 'advanced_settings', name: 'attach_app_logs', value: 'true'}, + ]); + + // # Log in as the user and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs status + await channelsPage.postMessage('/mobile-logs status'); + + // * Verify ephemeral response shows enabled + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Mobile app log attachment is currently enabled for you'); + }, + ); + + /** + * @objective Verify that /mobile-logs displays a usage hint when invoked + * without arguments or with an invalid action. + */ + test('MM-T67880 displays usage hint for invalid or missing arguments', {tag: '@slash_commands'}, async ({pw}) => { + // # Initialize setup + const {team, user} = await pw.initSetup(); + + // # Log in as the user and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs with an invalid action + await channelsPage.postMessage('/mobile-logs invalid'); + + // * Verify usage message is shown + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Usage: /mobile-logs [on|off|status] [@username]'); + }); + + /** + * @objective Verify that a system admin can enable mobile logs for another user + * and the preference is persisted on the target user's account. + */ + test( + 'MM-T67880 admin enables mobile logs for another user and preference is persisted', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup + const {team, adminUser, user, userClient} = await pw.initSetup(); + + // # Log in as admin and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Execute /mobile-logs on for the regular user + await channelsPage.postMessage(`/mobile-logs on @${user.username}`); + + // * Verify ephemeral response confirms logs enabled for the target user + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText(`Mobile app log attachment is now enabled for @${user.username}`); + + // * Verify the preference was set on the target user via API + const logPref = await getAttachLogsPreference(userClient, user.id); + expect(logPref).toBeDefined(); + expect(logPref!.value).toBe('true'); + }, + ); + + /** + * @objective Verify that a system admin can disable mobile logs for another user + * after they have been enabled. + */ + test( + 'MM-T67880 admin disables mobile logs for another user after enabling via API', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup + const {team, adminUser, user, userClient} = await pw.initSetup(); + + // # Pre-enable the preference for the target user via API + const {adminClient} = await pw.getAdminClient(); + await adminClient.savePreferences(user.id, [ + {user_id: user.id, category: 'advanced_settings', name: 'attach_app_logs', value: 'true'}, + ]); + + // # Log in as admin and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Disable for the regular user + await channelsPage.postMessage(`/mobile-logs off @${user.username}`); + + // * Verify ephemeral response confirms logs disabled + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText(`Mobile app log attachment is now disabled for @${user.username}`); + + // * Verify the preference was set to false on the target user + const logPref = await getAttachLogsPreference(userClient, user.id); + expect(logPref).toBeDefined(); + expect(logPref!.value).toBe('false'); + }, + ); + + /** + * @objective Verify that a non-admin user is denied when attempting to modify + * another user's mobile logs preference. + */ + test( + 'MM-T67880 rejects mobile log modification when caller is not an admin', + {tag: '@slash_commands'}, + async ({pw}) => { + // # Initialize setup with two regular users + const {team, adminClient, user} = await pw.initSetup(); + + const otherUser = await pw.random.user('other'); + const {id: otherUserId} = await adminClient.createUser(otherUser, '', ''); + await adminClient.addToTeam(team.id, otherUserId); + + // # Log in as the first regular user + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Try to enable mobile logs for the other user + await channelsPage.postMessage(`/mobile-logs on @${otherUser.username}`); + + // * Verify permission denied message + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Unable to change mobile log settings for that user.'); + }, + ); + + /** + * @objective Verify that a regular user cannot infer whether a username exists when + * attempting to target another account (same denial as missing permission). + */ + test( + 'MM-T67880 regular user gets permission denial for nonexistent cross-user target', + {tag: '@slash_commands'}, + async ({pw}) => { + const {team, user} = await pw.initSetup(); + + const {channelsPage} = await pw.testBrowser.login(user); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Post the mobile-logs command + await channelsPage.postMessage('/mobile-logs on @nonexistentuser12345'); + + const lastPost = await channelsPage.getLastPost(); + // * Expect permission denial message + await lastPost.toContainText('Unable to change mobile log settings for that user.'); + }, + ); + + /** + * @objective Verify that a system admin can check the mobile logs status for another + * user and it correctly reflects the current preference state. + */ + test('MM-T67880 admin checks mobile logs status for another user', {tag: '@slash_commands'}, async ({pw}) => { + // # Initialize setup + const {team, adminUser, user} = await pw.initSetup(); + + // # Log in as admin and navigate to town-square + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Check status (default off) + await channelsPage.postMessage(`/mobile-logs status @${user.username}`); + + // * Verify status shows disabled + const statusOffPost = await channelsPage.getLastPost(); + await statusOffPost.toContainText(`Mobile app log attachment is currently disabled for @${user.username}`); + }); + + /** + * @objective Verify that /mobile-logs returns a user-not-found error when targeting + * a nonexistent username. + */ + test('MM-T67880 returns error for nonexistent target user', {tag: '@slash_commands'}, async ({pw}) => { + // # Initialize setup + const {team, adminUser} = await pw.initSetup(); + + // # Log in as admin + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Try to enable for a nonexistent user + await channelsPage.postMessage('/mobile-logs on @nonexistentuser12345'); + + // * Verify user not found message + const lastPost = await channelsPage.getLastPost(); + await lastPost.toContainText('Could not find user "nonexistentuser12345"'); + }); +}); diff --git a/server/channels/app/slashcommands/command_mobile_logs.go b/server/channels/app/slashcommands/command_mobile_logs.go new file mode 100644 index 00000000000..76f6ae6d5c4 --- /dev/null +++ b/server/channels/app/slashcommands/command_mobile_logs.go @@ -0,0 +1,246 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package slashcommands + +import ( + "net/http" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/app" +) + +type MobileLogsProvider struct { +} + +const ( + CmdMobileLogs = "mobile-logs" +) + +func init() { + app.RegisterCommandProvider(&MobileLogsProvider{}) +} + +func mobileLogsCrossUserUnavailableResponse(args *model.CommandArgs) *model.CommandResponse { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.cross_user_unavailable.app_error"), + ResponseType: model.CommandResponseTypeEphemeral, + } +} + +func logMobileLogsAttachAppLogsAudit(a *app.App, rctx request.CTX, actorUserID, targetUserID, value string) { + rec := &model.AuditRecord{ + EventName: model.AuditEventUpdatePreferences, + Status: model.AuditStatusSuccess, + Actor: model.AuditEventActor{ + UserId: actorUserID, + SessionId: rctx.Session().Id, + Client: rctx.UserAgent(), + IpAddress: rctx.IPAddress(), + XForwardedFor: rctx.XForwardedFor(), + }, + Meta: map[string]any{ + model.AuditKeyAPIPath: rctx.Path(), + model.AuditKeyClusterID: a.GetClusterId(), + }, + EventData: model.AuditEventData{ + Parameters: map[string]any{ + "source": "slash_command/" + CmdMobileLogs, + "target_user_id": targetUserID, + "preference_category": model.PreferenceCategoryAdvancedSettings, + "preference_name": model.PreferenceNameAttachAppLogs, + "value": value, + }, + PriorState: map[string]any{}, + ResultState: map[string]any{}, + ObjectType: "user_preference", + }, + } + a.LogAuditRecWithLevel(rctx, rec, app.LevelAPI, nil) +} + +func (*MobileLogsProvider) GetTrigger() string { + return CmdMobileLogs +} + +func (*MobileLogsProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command { + return &model.Command{ + Trigger: CmdMobileLogs, + AutoComplete: true, + AutoCompleteDesc: T("api.command_mobile_logs.desc"), + AutoCompleteHint: T("api.command_mobile_logs.hint"), + DisplayName: T("api.command_mobile_logs.name"), + } +} + +func (*MobileLogsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse { + fields := strings.Fields(message) + if len(fields) == 0 || len(fields) > 2 { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.usage"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + + action := strings.ToLower(fields[0]) + if action != "on" && action != "off" && action != "status" { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.usage"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + + targetUserID := args.UserId + targetDisplayName := args.T("api.command_mobile_logs.you") + + if len(fields) > 1 { + username := strings.TrimPrefix(fields[1], "@") + username = strings.ToLower(username) + + if !model.IsValidUsername(username) { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + + caller, appErr := a.GetUser(args.UserId) + if appErr != nil { + rctx.Logger().Error("Failed to get caller for mobile-logs command", mlog.String("user_id", args.UserId), mlog.Err(appErr)) + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.update_error.app_error"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + + isSelf := username == strings.ToLower(caller.Username) + if isSelf { + targetUserID = caller.Id + targetDisplayName = "@" + caller.Username + } else { + // Cross-user: callers without system admin get one neutral outcome for any failure + // (unknown user, deactivated, disallowed target, or missing role) to avoid username + // enumeration. System admins still get explicit not-found messages for support workflows. + if !a.HasPermissionTo(args.UserId, model.PermissionManageSystem) && !a.HasPermissionTo(args.UserId, model.PermissionEditOtherUsers) { + return mobileLogsCrossUserUnavailableResponse(args) + } + + callerHasManageSystem := a.HasPermissionTo(args.UserId, model.PermissionManageSystem) + + targetUser, lookupErr := a.GetUserByUsername(username) + if lookupErr != nil { + if lookupErr.StatusCode == http.StatusNotFound { + if callerHasManageSystem { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + return mobileLogsCrossUserUnavailableResponse(args) + } + rctx.Logger().Error("Failed to get user by username", mlog.String("username", username), mlog.Err(lookupErr)) + if callerHasManageSystem { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + return mobileLogsCrossUserUnavailableResponse(args) + } + + if targetUser.DeleteAt != 0 { + if callerHasManageSystem { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + return mobileLogsCrossUserUnavailableResponse(args) + } + + targetUserID = targetUser.Id + targetDisplayName = "@" + targetUser.Username + + if !callerHasManageSystem && targetUser.IsSystemAdmin() { + return mobileLogsCrossUserUnavailableResponse(args) + } + } + } + + switch action { + case "on": + prefs := model.Preferences{ + { + UserId: targetUserID, + Category: model.PreferenceCategoryAdvancedSettings, + Name: model.PreferenceNameAttachAppLogs, + Value: "true", + }, + } + if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil { + rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err)) + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.update_error.app_error"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "true") + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.enabled", map[string]any{"User": targetDisplayName}), + ResponseType: model.CommandResponseTypeEphemeral, + } + + case "off": + prefs := model.Preferences{ + { + UserId: targetUserID, + Category: model.PreferenceCategoryAdvancedSettings, + Name: model.PreferenceNameAttachAppLogs, + Value: "false", + }, + } + if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil { + rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err)) + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.update_error.app_error"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "false") + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.disabled", map[string]any{"User": targetDisplayName}), + ResponseType: model.CommandResponseTypeEphemeral, + } + + case "status": + pref, err := a.GetPreferenceByCategoryAndNameForUser(rctx, targetUserID, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + if err != nil { + rctx.Logger().Debug("Could not get attach_app_logs preference, defaulting to off", mlog.String("user_id", targetUserID), mlog.Err(err)) + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + if pref.Value == "true" { + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.status_on", map[string]any{"User": targetDisplayName}), + ResponseType: model.CommandResponseTypeEphemeral, + } + } + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}), + ResponseType: model.CommandResponseTypeEphemeral, + } + + default: + // Defensive: action is already validated to be "on", "off", or "status" above. + return &model.CommandResponse{ + Text: args.T("api.command_mobile_logs.usage"), + ResponseType: model.CommandResponseTypeEphemeral, + } + } +} diff --git a/server/channels/app/slashcommands/command_mobile_logs_test.go b/server/channels/app/slashcommands/command_mobile_logs_test.go new file mode 100644 index 00000000000..c1fb6500beb --- /dev/null +++ b/server/channels/app/slashcommands/command_mobile_logs_test.go @@ -0,0 +1,284 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package slashcommands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" +) + +func TestMobileLogsGetCommand(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + result := cmd.GetCommand(th.App, i18n.IdentityTfunc()) + assert.Equal(t, CmdMobileLogs, result.Trigger) + assert.True(t, result.AutoComplete) + assert.NotEmpty(t, result.AutoCompleteDesc) + assert.NotEmpty(t, result.AutoCompleteHint) + assert.NotEmpty(t, result.DisplayName) +} + +func TestMobileLogsOnSelf(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "true", pref.Value) +} + +func TestMobileLogsOffSelf(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on") + + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "off") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.disabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "false", pref.Value) +} + +func TestMobileLogsStatusDefault(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "status") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.status_off", resp.Text) +} + +func TestMobileLogsStatusOn(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on") + + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "status") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.status_on", resp.Text) +} + +func TestMobileLogsOnOtherUserWithPermission(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "true", pref.Value) +} + +func TestMobileLogsOffOtherUserWithPermission(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser.Username) + + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "off @"+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.disabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "false", pref.Value) +} + +func TestMobileLogsStatusOtherUser(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + + // Default status for other user + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "status @"+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.status_off", resp.Text) + + // Enable for other user, then check status + cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser.Username) + + resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "status @"+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.status_on", resp.Text) +} + +func TestMobileLogsOnOtherUserWithoutPermission(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on @"+th.BasicUser2.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.cross_user_unavailable.app_error", resp.Text) +} + +func TestMobileLogsOnOtherUserWithoutAtPrefix(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on "+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "true", pref.Value) +} + +func TestMobileLogsNoArgs(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.usage", resp.Text) +} + +func TestMobileLogsTooManyArgs(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser.Username) + + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser.Username+" trailing") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.usage", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "true", pref.Value) +} + +func TestMobileLogsInvalidAction(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "invalid") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.usage", resp.Text) +} + +func TestMobileLogsUserNotFound(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @nonexistentuser12345") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.user_not_found.app_error", resp.Text) +} + +func TestMobileLogsNonexistentTargetAsRegularUserReturnsNoPermission(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on @nonexistentuser12345") + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.cross_user_unavailable.app_error", resp.Text) +} + +func TestMobileLogsOnSelfWithAtUsername(t *testing.T) { + th := setup(t).initBasic(t) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "on @"+th.BasicUser.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text) + + pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs) + require.Nil(t, err) + assert.Equal(t, "true", pref.Value) +} + +func TestMobileLogsDeactivatedUser(t *testing.T) { + th := setup(t).initBasic(t) + + _, err := th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, err) + + cmd := &MobileLogsProvider{} + resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.SystemAdminUser.Id, + }, "on @"+th.BasicUser2.Username) + assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType) + assert.Equal(t, "api.command_mobile_logs.user_not_found.app_error", resp.Text) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index fbc198f0f5f..e3a48fe9bb0 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1285,6 +1285,54 @@ "id": "api.command_me.name", "translation": "me" }, + { + "id": "api.command_mobile_logs.cross_user_unavailable.app_error", + "translation": "Unable to change mobile log settings for that user." + }, + { + "id": "api.command_mobile_logs.desc", + "translation": "Manage mobile app log attachment for yourself or another user." + }, + { + "id": "api.command_mobile_logs.disabled", + "translation": "Mobile app log attachment is now disabled for {{.User}}." + }, + { + "id": "api.command_mobile_logs.enabled", + "translation": "Mobile app log attachment is now enabled for {{.User}}." + }, + { + "id": "api.command_mobile_logs.hint", + "translation": "[on|off|status] [@username]" + }, + { + "id": "api.command_mobile_logs.name", + "translation": "mobile-logs" + }, + { + "id": "api.command_mobile_logs.status_off", + "translation": "Mobile app log attachment is currently disabled for {{.User}}." + }, + { + "id": "api.command_mobile_logs.status_on", + "translation": "Mobile app log attachment is currently enabled for {{.User}}." + }, + { + "id": "api.command_mobile_logs.update_error.app_error", + "translation": "Failed to update mobile app log attachment setting. Please try again." + }, + { + "id": "api.command_mobile_logs.usage", + "translation": "Usage: /mobile-logs [on|off|status] [@username]" + }, + { + "id": "api.command_mobile_logs.user_not_found.app_error", + "translation": "Could not find user \"{{.Username}}\"." + }, + { + "id": "api.command_mobile_logs.you", + "translation": "you" + }, { "id": "api.command_msg.desc", "translation": "Send Direct Message to a user" diff --git a/server/public/model/preference.go b/server/public/model/preference.go index d2ebb0108e1..461aa0690c7 100644 --- a/server/public/model/preference.go +++ b/server/public/model/preference.go @@ -30,6 +30,7 @@ const ( // - "join_leave" // - "unread_scroll_position" // - "sync_drafts" + // - "attach_app_logs" // - "feature_enabled_markdown_preview" <- deprecated in favor of "formatting" PreferenceCategoryAdvancedSettings = "advanced_settings" // PreferenceCategoryFlaggedPost is used to store the user's saved posts. @@ -77,6 +78,7 @@ const ( // PreferenceCategoryTheme has the name for the team id where theme is set. PreferenceCategoryTheme = "theme" + PreferenceNameAttachAppLogs = "attach_app_logs" PreferenceNameCollapsedThreadsEnabled = "collapsed_reply_threads" PreferenceNameChannelDisplayMode = "channel_display_mode" PreferenceNameCollapseSetting = "collapse_previews"