mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-30 17:06:34 +08:00
[MM-67880] Add /mobile-logs slash command (#35658)
* [MM-67880] Add /mobile-logs slash command with E2E tests Add a new /mobile-logs slash command that allows users to manage the attach_app_logs preference for themselves or other users (admin-only). Includes unit tests for all code paths and Playwright E2E tests covering self-management, admin cross-user management, permission denial, and error handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix i18n error key suffixes and format E2E tests Rename error i18n keys to use .app_error suffix matching upstream convention (no_permission, update_error, user_not_found). Run prettier on the E2E test file to fix formatting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix E2E test type error: use getUserPreferences instead of getMyPreferences The getAttachLogsPreference helper was using getMyPreferences() which returns PreferenceType (not an array), causing TS2345 errors. Switch to getUserPreferences(userId) which returns the expected array type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * [MM-67880] Move unreachable usage fallback into switch default case The return after the switch was unreachable because action is validated earlier to be "on", "off", or "status". Move it into an explicit default case with a defensive comment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * allow up to 2 arguments * Add audit logging for mobile logs slash command actions Implement a new function to log audit records when users enable or disable the attach_app_logs preference via the /mobile-logs command. This includes capturing relevant metadata such as user IDs, session information, and the action taken. The logging occurs in both the enable and disable command paths, enhancing traceability and accountability for user preference changes. * Enhance mobile logs command to handle cross-user permission checks Add a new response function for cases where a regular user attempts to access mobile log settings for another user, ensuring they receive a neutral error message instead of specific user information. Update the command logic to incorporate this response for both nonexistent users and deactivated accounts. Additionally, modify related tests and internationalization keys to reflect these changes, improving security and user experience. * Update E2E test for /mobile-logs command to verify permission denial for nonexistent users Enhance the existing E2E test for the /mobile-logs command by adding assertions to check that users receive a permission denial message when attempting to change mobile log settings for a nonexistent user. This improves test coverage and ensures proper error handling in the application. * update i18n strings --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
@@ -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<Array<{category: string; name: string; value: string}>>;
|
||||
},
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user